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.
Description
On the
2026-07-28HTTP path, everytools/callwith arguments runs the server's fulltools/listhandler before dispatch, to find the called tool'sinputSchemaforMcp-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 declaresx-mcp-header.For a server whose
tools/listis expensive this is a large per-call cost. We run a gateway that aggregates ~7 backend MCP servers; itstools/listfans out to every backend with per-user credentials, so eachtools/callnow lists every backend. #3033 notes this cost as "optimizable later"; a by-name schema lookup, or skipping the check when no tool declaresx-mcp-header, would avoid it.Expected: a
tools/calldoesn't invoke thetools/listhandler (or there's a way to avoid it). Actual: one full listing pertools/call.Example Code
Prints
1. Withmode="legacy"it prints0.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.