Skip to content

Mcp-Param-* validation runs the full tools/list handler on every tools/call #3565

Description

@kratsg

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    spec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revisionv2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions