You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
_mcp_param_rejection / _tool_input_schema in src/mcp/server/_streamable_http_modern.py (added by #3033, merged 2026-06-30) resolve a called tool's inputSchema by running the server's registered tools/list handler — through the full serve_one dispatch path (middleware, lifespans, up to _MCP_PARAM_LIST_PAGE_CAP = 100 pages) — before every tools/call that has non-empty arguments or any Mcp-Param-* header, on the 2026-07-28 protocol revision. This runs even when the tool being called advertises no x-mcp-header parameters at all, and there is no configuration to skip it. It is skipped only when app.get_request_handler("tools/list") is None or the call has neither arguments nor Mcp-Param-* headers (_mcp_param_rejection, lines 345–358).
For a server whose tools/list handler is itself expensive — an aggregator that fans a listing out to several backends, each requiring a per-user credential to enumerate — this turns every tool call into a full catalog refresh.
Where
src/mcp/server/_streamable_http_modern.py:
_tool_input_schema (lines 264–329): pages through serve_one(app, dctx, "tools/list", ...) to find one tool's schema.
_mcp_param_rejection (lines 332–362): the gate that calls it, pre-dispatch, for every tools/call with arguments or Mcp-Param-* headers.
handle_modern_request (line 438): calls _mcp_param_rejection before constructing the SSE deferral machinery.
The cost is an internal listing per validated tools/call: middleware, lifespans, and expensive/paginated tools/list handlers see extra invocations … optimizable later behind the same surface (e.g. a registry fast-path for the built-in handler).
A review comment on the PR (on src/mcp/server/_streamable_http_modern.py:399) separately flags that this validation runs pre-dispatch, before the SSE deferral/keepalive window exists:
In SSE mode (json_response=False), the new pre-dispatch Mcp-Param validation phase runs before the SSE deferral/keepalive machinery, so a 2026-07-28 tools/call writes no bytes to the wire while the internal tools/list schema walk runs (up to 100 paginated serve_one round trips). A deployment whose tools/list handler is slower than the upstream proxy's idle-read timeout previously worked (the keepalive committed within 15s of dispatch) but would now have every validated tools/call reset before dispatch — consider bounding the schema-resolving walk with a timeout that degrades to the existing logged fail-open skip.
That comment does not appear to have been acted on in 2.2.0 — _tool_input_schema has no timeout around the serve_one loop, only the page-count cap.
Aggregator impact (observed in production)
We run an MCP aggregating gateway (fastmcp 4.0.4 FastMCP with one ProxyProvider per backend MCP server, mounted via add_provider, ~7 backends, per-user credentials minted for backend requests). FastMCP's tools/list handler (AggregateProvider._list_tools) queries every provider's list_tools() in parallel; for a ProxyProvider this is a live upstream tools/list call, credential-minting included (see companion issue filed against PrefectHQ/fastmcp, which also has a caching gap on this path).
Observed:
One tools/call → 7 backend tools/list calls + 4 Kerberos ticket redemptions (credential minting triggered by listing certain backends).
A client batch of 10 parallel tools/calls → roughly 70 backend listings.
The call itself only ever needs one tool's schema. The server can resolve that by name — FastMCP.get_tool(name) reaches this without a full fan-out: AggregateProvider._get_tool still queries every child provider, but each namespaced child is wrapped in a Namespace transform whose get_tool short-circuits to None without calling the underlying provider when the name's prefix doesn't match (fastmcp/server/transforms/namespace.py), and the one matching ProxyProvider._get_tool reads from its own _tools_cache when fresh (cache_ttl, default 300s) instead of hitting the network. None of that machinery is reachable from _tool_input_schema, which only knows how to call the registered tools/list handler.
Proposed fixes
By-name resolution hook. Let a lowlevel Server optionally accept a by-name schema resolver (e.g. an on_get_tool_schema(ctx, name) -> dict | None alongside on_list_tools) that _tool_input_schema prefers over the tools/list walk when present. MCPServer and downstream frameworks built on the lowlevel Server (e.g. fastmcp) can implement it against their own by-name lookup instead of paying for a full listing. Falls back to the existing tools/list walk when the hook is absent, so behavior for a bare Server with only a tools/list handler is unchanged.
Skip when no tool declares x-mcp-header. A server could compute once (e.g. at the first tools/list, or lazily and cache) whether any registered tool actually declares an x-mcp-header-annotated property. If none do, no Mcp-Param-* header can ever be meaningfully violated for any tool, and the whole check — including the schema resolution — can be skipped server-wide. Note: MCPServer currently has no declaration/validation mechanism for x-mcp-header server-side (see MCPServer has no x-mcp-header declaration mechanism and never validates one, so an invalid annotation is served happily and dropped by every client #3484), so this flag would need to inspect raw inputSchema properties for the annotation key directly, not rely on a first-class registration API.
At minimum, a documented, explicit opt-out (constructor flag or env var) for deployments that accept the compliance gap in exchange for not paying full-listing cost per call, given some servers' tools/list handlers are cheap and others' are not.
Minimal reproduction sketch
frommcpimporttypesfrommcp.server.lowlevel.serverimportServerlist_calls=0asyncdefon_list_tools(ctx, params):
globallist_callslist_calls+=1returntypes.ListToolsResult(tools=[...]) # one tool with an inputSchemaasyncdefon_call_tool(ctx, params):
returntypes.CallToolResult(content=[...])
server=Server("repro", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
# Serve over Streamable HTTP and send one `tools/call` with non-empty# `arguments` from a client on protocol 2026-07-28 (no tools/list sent by the# client). Observe list_calls == 1: the server listed its whole catalog to# validate a single call.
PR #4622 (PrefectHQ/fastmcp, merged) — confirms fastmcp's HTTP transport passes Mcp-Param-*/x-mcp-header through untouched; does not address the per-call listing cost.
Summary
_mcp_param_rejection/_tool_input_schemainsrc/mcp/server/_streamable_http_modern.py(added by #3033, merged 2026-06-30) resolve a called tool'sinputSchemaby running the server's registeredtools/listhandler — through the fullserve_onedispatch path (middleware, lifespans, up to_MCP_PARAM_LIST_PAGE_CAP = 100pages) — before everytools/callthat has non-emptyargumentsor anyMcp-Param-*header, on the2026-07-28protocol revision. This runs even when the tool being called advertises nox-mcp-headerparameters at all, and there is no configuration to skip it. It is skipped only whenapp.get_request_handler("tools/list") is Noneor the call has neither arguments norMcp-Param-*headers (_mcp_param_rejection, lines 345–358).For a server whose
tools/listhandler is itself expensive — an aggregator that fans a listing out to several backends, each requiring a per-user credential to enumerate — this turns every tool call into a full catalog refresh.Where
src/mcp/server/_streamable_http_modern.py:_tool_input_schema(lines 264–329): pages throughserve_one(app, dctx, "tools/list", ...)to find one tool's schema._mcp_param_rejection(lines 332–362): the gate that calls it, pre-dispatch, for everytools/callwith arguments orMcp-Param-*headers.handle_modern_request(line 438): calls_mcp_param_rejectionbefore constructing the SSE deferral machinery.PR #3033 acknowledges the cost
From the PR description:
A review comment on the PR (on
src/mcp/server/_streamable_http_modern.py:399) separately flags that this validation runs pre-dispatch, before the SSE deferral/keepalive window exists:That comment does not appear to have been acted on in 2.2.0 —
_tool_input_schemahas no timeout around theserve_oneloop, only the page-count cap.Aggregator impact (observed in production)
We run an MCP aggregating gateway (fastmcp 4.0.4
FastMCPwith oneProxyProviderper backend MCP server, mounted viaadd_provider, ~7 backends, per-user credentials minted for backend requests).FastMCP'stools/listhandler (AggregateProvider._list_tools) queries every provider'slist_tools()in parallel; for aProxyProviderthis is a live upstreamtools/listcall, credential-minting included (see companion issue filed against PrefectHQ/fastmcp, which also has a caching gap on this path).Observed:
tools/call→ 7 backendtools/listcalls + 4 Kerberos ticket redemptions (credential minting triggered by listing certain backends).tools/calls → roughly 70 backend listings.The call itself only ever needs one tool's schema. The server can resolve that by name —
FastMCP.get_tool(name)reaches this without a full fan-out:AggregateProvider._get_toolstill queries every child provider, but each namespaced child is wrapped in aNamespacetransform whoseget_toolshort-circuits toNonewithout calling the underlying provider when the name's prefix doesn't match (fastmcp/server/transforms/namespace.py), and the one matchingProxyProvider._get_toolreads from its own_tools_cachewhen fresh (cache_ttl, default 300s) instead of hitting the network. None of that machinery is reachable from_tool_input_schema, which only knows how to call the registeredtools/listhandler.Proposed fixes
Serveroptionally accept a by-name schema resolver (e.g. anon_get_tool_schema(ctx, name) -> dict | Nonealongsideon_list_tools) that_tool_input_schemaprefers over thetools/listwalk when present.MCPServerand downstream frameworks built on the lowlevelServer(e.g. fastmcp) can implement it against their own by-name lookup instead of paying for a full listing. Falls back to the existingtools/listwalk when the hook is absent, so behavior for a bareServerwith only atools/listhandler is unchanged.x-mcp-header. A server could compute once (e.g. at the firsttools/list, or lazily and cache) whether any registered tool actually declares anx-mcp-header-annotated property. If none do, noMcp-Param-*header can ever be meaningfully violated for any tool, and the whole check — including the schema resolution — can be skipped server-wide. Note:MCPServercurrently has no declaration/validation mechanism forx-mcp-headerserver-side (see MCPServer has no x-mcp-header declaration mechanism and never validates one, so an invalid annotation is served happily and dropped by every client #3484), so this flag would need to inspect rawinputSchemaproperties for the annotation key directly, not rely on a first-class registration API.tools/listhandlers are cheap and others' are not.Minimal reproduction sketch
Related
MCPServerhas nox-mcp-headerdeclaration or validation mechanism server-side; relevant to proposal (2) above, since there is currently no first-class way to ask "does any tool declarex-mcp-header" other than inspecting raw schemas.ClientSession.call_toolissues atools/listafter everytools/callwhen the output-schema cache is empty, with the same aggregator fan-out cost and no opt-out. Filed independently but the same underlying pattern (validation/caching bolted onto the request path via a full listing instead of a by-name lookup).Mcp-Param-*/x-mcp-headerthrough untouched; does not address the per-call listing cost.🤖 Generated with Claude Code