Repository navigation
Mcp-Param-* validation runs the full tools/list handler on every tools/call #3565
Description
Description
On the 2026-07-28 HTTP path, every tools/call with arguments runs the server's full tools/list handler before dispatch, to find the called tool's inputSchema for Mcp-Param-* validation (_mcp_param_rejection → _tool_input_schema, added in #3033). There is no way to opt out, and it runs even when no tool declares x-mcp-header.
For a server whose tools/list is expensive this is a large per-call cost. We run a gateway that aggregates ~7 backend MCP servers; its tools/list fans out to every backend with per-user credentials, so each tools/call now lists every backend. #3033 notes this cost as "optimizable later"; a by-name schema lookup, or skipping the check when no tool declares x-mcp-header, would avoid it.
Expected: a tools/call doesn't invoke the tools/list handler (or there's a way to avoid it). Actual: one full listing per tools/call.
Example Code
import asyncio
import threading
import time
import uvicorn
from mcp import types
from mcp.client.client import Client
from mcp.server.lowlevel.server import Server
list_calls = 0
async def on_list_tools(ctx, params):
global list_calls
list_calls += 1
return types.ListToolsResult(
tools=[
types.Tool(
name="echo",
inputSchema={"type": "object", "properties": {"text": {"type": "string"}}},
)
]
)
async def on_call_tool(ctx, params):
# How many times tools/list ran before this call was dispatched.
return types.CallToolResult(content=[types.TextContent(type="text", text=str(list_calls))])
server = Server("repro", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
app = server.streamable_http_app(stateless_http=True, json_response=True)
threading.Thread(
target=uvicorn.Server(uvicorn.Config(app, port=8765, log_level="warning")).run, daemon=True
).start()
time.sleep(1)
async def main():
async with Client("http://127.0.0.1:8765/mcp", mode="2026-07-28") as client:
result = await client.call_tool("echo", {"text": "hi"})
print(f"tools/list calls before the tools/call was dispatched: {result.content[0].text}")
asyncio.run(main())Prints 1. With mode="legacy" it prints 0.
Python & MCP Python SDK
Python 3.12.13, mcp 2.2.0
Related: #3513 (client-side analog), #3484.
Drafted with AI assistance (Claude Code); the reproduction above was run against mcp 2.2.0.
Activity
0xamlab commentedon Sep 23, 2026
We hit this exact wall operating a 140-tool MCP server: a full
tools/listpertools/callis ~19 ms of pure overhead per call at that registry size (measured on our side before we started memoizing schema conversion — see PR #2839 for the same class of fix). A by-name fast path (validate against the cached tool map built at registration) plus an opt-out for trusted servers would kill both costs without weakening SEP-2243 conformance. Happy to PR with a benchmark test if a maintainer assigns.Affiliation: we build Algenta's MCP server and conformance-test against this SDK.
[-]SEP-2243 Mcp-Param-* validation runs a full `tools/list` per `tools/call`, with no opt-out and no by-name fast path[/-][+]Mcp-Param-* validation runs the full tools/list handler on every tools/call[/+]KardeniaPoyu commentedon Sep 25, 2026
+1 on @0xamlab's by-name fast path and opt-out suggestion.
A few quick implementation details that work cleanly:
MCPServer: Can auto-resolve schema via_tool_managerout-of-the-box (0 config, 0tools/listcalls).Server: Provide an optionalget_tool_input_schemahook, falling back to paginatedtools/listfor 100% backward compatibility.validate_mcp_params: bool = Trueto allow trusted gateways to bypass validation entirely.We have a working branch with unit tests (full test suite + pyright clean). Happy to open a PR or collaborate whenever a maintainer assigns.
zbarbur commentedon Sep 29, 2026
+1 from another fan-out gateway on
mcp2.2.0 (low-levelServer, stateless, JSON responses). We hit the same thing, and there is one consequence the issue doesn't mention yet: failure coupling, not just overhead.An aggregating proxy's
tools/listwaits on every backend. When a backend is cold or cache-expired, that means network calls and credential exchanges. Oncetools/callruns the full listing, a slow or unreachable backend can delay, or fail, a call aimed at a different, healthy backend. It also shifts discovery side effects (inventory and drift bookkeeping, token exchanges) onto the call path.We strip
x-mcp-headerfrom every schema we expose, so for us the validation never has anything to check.The
get_tool_input_schemahook proposed above is exactly what we'd use: we can answer by name from state we already hold. Thevalidate_mcp_paramsopt-out would also do. Until then we answer the listing cheaply ourselves when it is invoked for this validation, which leans on SDK internals we'd rather not depend on.KaiyiQuan commentedon Oct 2, 2026
Thanks for the thread — 0xamlab's 140-tool measurement and KardeniaPoyu's opt-out suggestion are exactly what we converged on. I've implemented the opt-out variant and it's ready for review; PR #3599 was auto-closed by the require-linked-issue gate before a maintainer could assign, so I'm posting the details here.
Implementation (branch
fix/3565-mcp-param-validation-optouton github.com/KaiyiQuan/python-sdk):Servergainsmcp_param_validation: bool = True. WhenFalse,_mcp_param_rejectionreturns immediately, so an argument-bearingtools/callno longer runs the schema-resolvingtools/listhandler at all. Default staysTrue, so every existing Mcp-Param-* rejection (missing/mismatch/orphan/duplicate header) is byte-for-byte unchanged; theMcpServerwrapper delegates to the same app, so the flag is honored there too.tools/callreturns 200 and thetools/listhandler is never invoked; with the flag on (default), the missing-header 400HEADER_MISMATCHstill fires.I deliberately did not do a header-presence shortcut (skip the walk when no Mcp-Param-* header is present): the SDK pins a 400 for a present annotated argument with a missing header (test_modern_tools_call_rejects_missing_mcp_param_header_for_present_argument), and a pure shortcut would break that contract. A maintainer assigning me to #3565 reopens PR #3599 automatically.
maxisbey commentedon Oct 2, 2026
Closed by #3630, thanks for the clear report and to everyone who added gateway details.
MCPServernow finds the called tool's schema by name, with nothing to configure.The low-level
Servertakes an optionalget_tool_input_schema=function (tool name in, schema orNoneout) that runs instead of thetools/listhandler. It's opt-in, so without it the listing still runs. A gateway with nox-mcp-headertools can passlambda name: None. Schemas by name shows how.If it doesn't fit your setup, you're welcome to open a new issue.
AI Disclaimer