DEV Community

pm25coder
pm25coder

Posted on

The MCP header check is silently off for a tool your caller can't see

I sent one bad request to three MCP servers on mcp==2.3.0. Two answered 400 and named the argument the request was missing. One answered 200 and ran the tool anyway, with the argument still missing.

That third server was the one whose tools/list did not advertise the tool I called. Nothing in its behaviour said "the check is off" — the call simply succeeded, and it succeeded because of a visibility rule, not because of anything I configured.

The request

MCP servers on the 2026-07-28 version can declare that a tool argument is mirrored into an HTTP header:

{"properties": {"job_id": {"type": "string", "x-mcp-header": "secret-tool-job-id"}}}
Enter fullscreen mode Exit fullscreen mode

The client then sends the value twice: once as Mcp-Param-secret-tool-job-id in the request headers, once inside arguments. (In my probe the header name is the Mcp-Param- prefix plus the tool and the argument with underscores turned into dashes, so secret_tool and job_id give Mcp-Param-secret-tool-job-id.) The server checks that the two agree, because anything upstream that routes or authorizes on the header is reading a promise about what the body was supposed to contain.

So the request I sent is the shape that check exists for. The header said job J-123. The body said:

{"name": "secret_tool", "arguments": {}}
Enter fullscreen mode Exit fullscreen mode

No job_id at all. Whatever reads the header believes this call is about job J-123; the tool that actually runs gets nothing.

Three servers, one request

Server secret_tool visible to this caller? Status What the handler received
mcp.server.lowlevel.Server no 200 arguments: {} — the tool ran
same low-level class, tool advertised yes 400 not reached; -32020 names the header and the missing argument
mcp.server.mcpserver.MCPServer, tool hidden by middleware no 400 not reached; same -32020

The second row is the one that makes the first row mean something. Same class, same handler, same request — only the listing differs. So the 200 is not "the low-level path does not validate": it does validate when the tool is in the listing. The 200 is caused by the tool not being in the listing.

Why the two paths differ

Before dispatching a tools/call that carries a marked argument, the server needs that tool's input schema. There are two ways it now gets one.

MCPServer looks the name up in its own registry (mcp/server/mcpserver/server.py:432):

def _tool_input_schema(self, name: str) -> dict[str, Any] | None:
    """Called before middleware runs, so it also finds a tool that middleware hides from the caller."""
    tool = self._tool_manager.get_tool(name)
    return None if tool is None else tool.parameters
Enter fullscreen mode Exit fullscreen mode

A plain Server without that keyword falls back to running its own tools/list handler, page by page, as the caller (mcp/server/_streamable_http_modern.py:299, abridged to the loop and the match):

for _ in range(_MCP_PARAM_LIST_PAGE_CAP):
    result = await serve_one(app, dctx, "tools/list", list_params, connection=connection, ...)
    for tool in result.get("tools", []):
        if tool.get("name") == name:
            return tool.get("inputSchema")
Enter fullscreen mode Exit fullscreen mode

_MCP_PARAM_LIST_PAGE_CAP is 100 (:260), which is the other thing this walk costs. And when the listing runs out without naming the tool (:317):

if not isinstance(cursor, str):
    # Listing exhausted without advertising `name`; dispatch owns rejecting an unknown tool.
    return None
Enter fullscreen mode Exit fullscreen mode

None means "nothing to validate", and :370 returns before the check runs. The registry lookup is scoped by what is registered; the listing walk is scoped by what this caller was shown. Those are the same set until something filters the listing — a per-tenant catalog, a permission layer, middleware that drops tools — and then they are not.

The upstream change is python-sdk PR #3630, merged 2026-10-02; the source I read above is the released mcp==2.3.0. Its own description names the case it fixes: "A registered tool that middleware hides from a caller used to be skipped. It is now validated like any other registered tool." For MCPServer that is automatic. For a low-level Server, the same pull request says a server without the new keyword "resolves the schema through tools/list exactly as before", which is the row my probe measured.

If that is your server

Pass the lookup. From the same pull request:

schemas: dict[str, dict[str, Any]] = {}   # kept up to date as you register tools

server = Server(
    "gateway",
    on_list_tools=list_tools,
    on_call_tool=call_tool,
    get_tool_input_schema=schemas.get,
)
Enter fullscreen mode Exit fullscreen mode

One property of that hook is worth knowing before you rely on it, because it fails in the same direction: a lookup that raises is logged and the call is served unvalidated. The comment in the source at _streamable_http_modern.py:367 is explicit about why — "Fail-open like a failed listing: header validation must never break a working call path". A dict.get cannot raise. A lookup that builds a schema from a backend can. If yours can, the fail-open is what you have on a bad day, and only a log line says so.

Reproduce it

Requires pip install "mcp==2.3.0". Both arms are the same class; the only difference is whether the listing advertises the tool.

import json
from starlette.testclient import TestClient
from mcp import types
from mcp.server.lowlevel import Server

PROTOCOL, HIDDEN, JOB_HEADER = "2026-07-28", "secret_tool", "Mcp-Param-secret-tool-job-id"
SCHEMA = {"type": "object",
          "properties": {"job_id": {"type": "string", "x-mcp-header": "secret-tool-job-id"}},
          "required": ["job_id"]}

def list_tools(advertise):
    async def handler(ctx, params):
        tools = [types.Tool(name="echo", description="echo",
                            input_schema={"type": "object", "properties": {"text": {"type": "string"}}})]
        if advertise:
            tools.append(types.Tool(name=HIDDEN, description="hidden", input_schema=SCHEMA))
        return types.ListToolsResult(tools=tools)
    return handler

async def call_tool(ctx, params):
    return types.CallToolResult(content=[types.TextContent(
        type="text", text=json.dumps({"tool": params.name, "arguments": dict(params.arguments or {})}))])

def app_for(advertise):
    server = Server(f"srv-{advertise}", version="1.0.0",
                    on_list_tools=list_tools(advertise), on_call_tool=call_tool)
    return server.streamable_http_app(json_response=True, stateless_http=True, host="127.0.0.1")

def bad_call(app):
    headers = {"content-type": "application/json",
               "accept": "application/json, text/event-stream",
               "mcp-protocol-version": PROTOCOL, "mcp-method": "tools/call",
               "mcp-name": HIDDEN, JOB_HEADER: "J-123"}
    body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
            "params": {"_meta": {"io.modelcontextprotocol/protocolVersion": PROTOCOL,
                                 "io.modelcontextprotocol/clientCapabilities": {}},
                       "name": HIDDEN, "arguments": {}}}
    with TestClient(app, base_url="http://127.0.0.1:8000") as client:
        r = client.post("/mcp", headers=headers, content=json.dumps(body))
    return r.status_code, r.text

for advertise, label in ((False, "hidden from tools/list"), (True, "advertised in tools/list")):
    status, body = bad_call(app_for(advertise))
    print(label, "->", status, body[:150])
Enter fullscreen mode Exit fullscreen mode

Mine printed, verbatim (bodies cut at 150 characters):

hidden from tools/list -> 200 {"jsonrpc":"2.0","id":1,"result":{"content":[{"text":"{\"tool\": \"secret_tool\", \"arguments\": {}}","type":"text"}],"isError":false,"resultType":"co
advertised in tools/list -> 400 {"jsonrpc":"2.0","id":1,"error":{"code":-32020,"message":"Mcp-Param-secret-tool-job-id header is present but the request body's 'job_id' argument is a
Enter fullscreen mode Exit fullscreen mode

The script above answers the question in your own repo: swap in the tool you filter out of your listing and the argument your header mirrors, and see which of the two statuses comes back.

What this is not

It is not a defect report. The pull request describes the low-level fallback as unchanged behaviour, the None return as intentional, and the fail-open as deliberate — three decisions, written down. That is why this is about knowing which server you have rather than about a patch.

It is also not a claim about most servers. The SDK docs open with it: marked argument values as headers exist so something in front of your server can route on what it can read without parsing the body, and "Most servers never need this." If no tool declares x-mcp-header, none of the above runs and the difference costs nothing. This only lands on servers that declare a marked argument and filter their listing.

My own measurement is one process: Starlette's TestClient over the real Streamable HTTP app, mcp==2.3.0, protocol 2026-07-28. I did not put it behind a separate worker or a real proxy, and the visibility filter in my third server was middleware that drops a name from every tools/list result.

The shape is worth carrying to any check, though: if the code that decides whether to validate resolves its schema "as the caller sees it", then every filter between the caller and the catalog also silently decides whether the check happens. Two paths that agree today disagree exactly when someone adds the filter, and the disagreement arrives as a 200.

Top comments (0)