Look the tool schema up by name for Mcp-Param-* validation instead of running tools/list

Merged
MA
1mo ago
+229-16

Fixes #3565.

What was wrong

On the 2026-07-28 Streamable HTTP path, a tools/call that carries arguments is checked against the called tool's x-mcp-header declarations before dispatch. To find that tool's input schema, the server ran its own tools/list handler, page by page, on every such call.

  • A server whose listing is expensive (an aggregating gateway, a large catalog) paid for a full listing per call.
  • A slow listing delayed the call, and a failing one logged an error for it.
  • A call to an unknown tool walked every page.

What changes

  • MCPServer looks the called tool up by name among its registered tools. Nothing to configure.

  • The low-level Server takes a new optional keyword, get_tool_input_schema=:

    schemas: dict[str, dict[str, Any]] = {}  # kept up to date as the gateway lists its backends
    
    server = Server(
        "gateway",
        on_list_tools=list_tools,  # fans out to every backend
        on_call_tool=call_tool,
        get_tool_input_schema=schemas.get,
    )
    
    • It takes the tool name and returns that tool's input schema, or None when there is nothing to validate.
    • When it is set, it is called instead of the tools/list handler.
    • If it raises, the error is logged and the call is served unvalidated, the same as a failed listing.
    • A server that declares no x-mcp-header on any tool can pass lambda name: None.
    • It is called before middleware runs, so it is not scoped to the caller.
    • The value is also readable as server.get_tool_input_schema.

What users will notice

  • MCPServer:
    • Middleware no longer sees a tools/list for each tools/call.
    • The schema that is validated is the registered one. Middleware that filters or rewrites tools/list results no longer affects validation.
      • A registered tool that middleware hides from a caller used to be skipped. It is now validated like any other registered tool.
      • The check runs before middleware. A caller who names a hidden tool and its marked argument, without the header, is answered with the header mismatch, which names the header and the argument, rather than with whatever middleware would have answered. The tool is not run.
      • The TypeScript, Go, C#, Rust and PHP SDKs also resolve the tool by name before dispatch.
    • A subclass that overrides MCPServer.list_tools() to add tools outside the registry no longer gets those tools validated.
  • Low-level Server with get_tool_input_schema=:
    • The tools/list handler does not run for a tools/call.
    • Validation runs even when no tools/list handler is registered.
    • None from the function skips validation for that call. It does not fall back to the listing.

What is unchanged

  • A low-level Server without the keyword resolves the schema through tools/list exactly as before.
  • How a header is compared with the body, the 400 status and the -32020 error code. Apart from the cases above, the same requests are accepted and rejected.
  • A call with no arguments and no Mcp-Param-* headers is still not validated.
  • Earlier protocol versions, stdio and in-memory transports. The check only exists on the 2026-07-28 HTTP entry.

Docs

  • docs/advanced/header-parameters.md gains a short section, "Schemas by name", under "On the low-level Server", with a runnable example (docs_src/header_parameters/tutorial003.py) and a recap line.
  • docs/advanced/low-level-server.md gains one bullet under "The other handlers" that points to that section.
  • The "Servers validate Mcp-Param-* headers against the request body" section is removed from docs/migration.md, along with the link to it in docs/whats-new.md. The check is new in 2026-07-28, so there is nothing to migrate from v1, and the section described the internal listing this change replaces.

How it was checked

  • New tests in tests/server/test_streamable_http_modern.py:
    • An MCPServer tool with an x-mcp-header argument is accepted with a matching header, rejected with a mismatched or missing one, and an unregistered tool is left to dispatch. Middleware sees only the two dispatched tools/calls. On main it also sees a tools/list for each request.
    • A low-level server with get_tool_input_schema gets the same outcomes and its tools/list handler never runs.
    • A raising get_tool_input_schema is logged and the call is served.
  • New tests in tests/docs_src/test_header_parameters.py, over the 2026-07-28 HTTP path: the page's low-level example without the keyword dispatches a tools/list before each tools/call, the one with it dispatches only the tools/call, and it still rejects a mismatched Mcp-Param-Region header.
  • The existing tests for the listing path (pagination, cursor cycle, page cap, raising handler, caller envelope) pass unchanged. They cover a low-level server without the keyword.
  • For every tool on the everything-server, and on a server with nested models, aliases, optional and annotated arguments, the registered schema equals the inputSchema that tools/list returns.
  • The http-custom-header-server-validation conformance scenario passes against the everything-server.
  • ./scripts/test (100% coverage), ruff and pyright are clean.

AI Disclaimer