Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

SEP-2243 Mcp-Param-* validation runs a full `tools/list` per `tools/call`, with no opt-out and no by-name fast path

Open
#3,565 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
45/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
python
Domain
api, backend

Research direction

The issue is in src/mcp/server/_streamable_http_modern.py, specifically the _tool_input_schema and _mcp_param_rejection functions. Start by reading the code around lines 264-362 to understand the validation flow. Run the minimal reproduction sketch to observe the behavior. Check related issues #3484 and #3513 for context. The fix involves adding a by-name resolution hook or an opt-out mechanism, requiring changes to the low-level Server class and the HTTP handling logic.

Written by the indexing model from the issue text.

Description

spec-2026-07-28 v2

Summary

_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.

PR #3033 acknowledges the cost

From the PR description:

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

  1. 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.
  2. 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 #3484), so this flag would need to inspect raw inputSchema properties for the annotation key directly, not rely on a first-class registration API.
  3. 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

from mcp import types
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=[...])  # one tool with an inputSchema

async def on_call_tool(ctx, params):
    return types.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.

Related

  • #3484 — MCPServer has no x-mcp-header declaration or validation mechanism server-side; relevant to proposal (2) above, since there is currently no first-class way to ask "does any tool declare x-mcp-header" other than inspecting raw schemas.
  • #3513 — client-side analog: ClientSession.call_tool issues a tools/list after every tools/call when 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).
  • PR #3033 (merged) — the change this issue is about.
  • 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.

🤖 Generated with Claude Code

Dominant language
Python
Stars
24.3k
Forks
4k
Avg merge
1d 11h
Merged PRs (30d)
30

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from modelcontextprotocol/python-sdk

All issues in modelcontextprotocol/python-sdk

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.