---
title: "MCP tool excluded from tools/list: invalid x-mcp-header declaration"
description: "Your MCP tool is missing from tools/list and no error was returned: one malformed x-mcp-header annotation makes conforming clients drop it, logging only a warning. The constraint it broke, and the fix."
canonicalUrl: "https://zuplo.com/learn/mcp/errors/excluded-tool-invalid-x-mcp-header"
pageType: "mcp-error"
errorString: "[mcp-sdk] excluding tool '<name>' from tools/list: invalid x-mcp-header declaration"
lastVerified: "2026-07-31"
specVersion: "2026-07-28"
---

# `[mcp-sdk] excluding tool '<name>' from tools/list: invalid x-mcp-header declaration`

> One parameter in that tool's `inputSchema` carries an `x-mcp-header` annotation that breaks a 2026-07-28 constraint, so a conforming Streamable HTTP client drops the whole tool from `tools/list` and logs a warning instead of raising an error.

_MCP specification 2026-07-28._

## Is this you?

Compare what the server returns against what the client passes on. The server's `tools/list` response still contains the tool, so seeing it in `curl` and not in your client is the confirmation — and it rules out a tool that failed to register or a capability filter in front of the route.

```bash
# 1. Does the server still return the tool? Read the raw response.
curl -sS -X POST https://your-server.example.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: tools/list' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{
       "io.modelcontextprotocol/protocolVersion":"2026-07-28",
       "io.modelcontextprotocol/clientCapabilities":{}}}}' \
  -o tools.json
jq -r '.result.tools[].name' tools.json

# 2. Where is every annotation declared? Print the schema path of each one.
#    A valid path alternates `properties` and a property name all the way
#    down; anything else in the path is the reason the tool was dropped.
jq -r '.result.tools[]
       | .name as $n
       | [.inputSchema | paths | select(.[-1] == "x-mcp-header")]
       | .[] | "\($n): \(join("."))"' tools.json
```

**This error** — Step 1 lists the tool and your client does not. Step 2 then prints a path with another keyword in it — `broken: properties.regions.items.x-mcp-header` is the common one, an annotation left on an array's `items` — or `oneOf`, `anyOf`, `allOf`, `not`, `if`, `then`, `else`, `$ref`, or `additionalProperties` in the same position. Two annotations whose values differ only in case, and an annotation on a property typed `number`, `object` or `array`, fail the same way. If step 1 does not list the tool either, this is not your problem: the tool never registered, or something in front of the route is filtering capabilities.

**Working** — Step 1 lists the tool and your client offers it too, which means no exclusion is happening. Every path step 2 prints alternates `properties` and a property name all the way down, as in `execute_sql: properties.region.x-mcp-header`, and each annotated property is typed `string`, `integer` or `boolean`. If this is what you see, this isn't your problem.

**Clients also report this as:**

- `[mcp-sdk] excluding tool '<name>' from tools/list: invalid x-mcp-header declaration — <reason>`
- `[mcp-sdk] tool '<name>' carries an invalid x-mcp-header declaration and will be excluded by conforming Streamable HTTP clients: <reason>`
- `dropping tool '<name>': invalid x-mcp-header (<reason>)`
- `<path>: x-mcp-header is only permitted on primitive-typed properties (string, integer, boolean); got <type>`
- `<path>: x-mcp-header is only permitted on properties statically reachable via a chain of 'properties' keys (not under items, additionalProperties, oneOf/anyOf/allOf/not, if/then/else, or $ref)`

## Fix it

**In short.** Find the annotation and fix the schema; the warning already names the tool and the reason. An `x-mcp-header` value must be a non-empty RFC 9110 token, case-insensitively unique across the schema, on a property typed `string`, `integer` or `boolean` — `number` is not permitted — and reachable from the schema root through a chain of `properties` keys only, never through `items`, `oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`, or `$ref`. If the tool is not yours, read the raw `tools/list` response with `curl`: the server still returns the tool, and the client removes it after the fact.

Which side of the tool list can you change?

### MCP TypeScript SDK

If your server or client is the MCP TypeScript SDK 2.x

1. Read the warning from whichever side logged it, because both sides have one and they are worded differently. The client logs `[mcp-sdk] excluding tool '<name>' from tools/list: invalid x-mcp-header declaration — <reason>` when it drops the tool. `McpServer.registerTool` warns at registration time instead: `[mcp-sdk] tool '<name>' carries an invalid x-mcp-header declaration and will be excluded by conforming Streamable HTTP clients: <reason>`. That server-side warning fires whether or not any client has connected, so it is the faster place to look.
2. Take the reason literally — the SDK names the failing property path and the constraint. Its messages cover the five cases separately: not `statically reachable via a chain of 'properties' keys`, not `a non-empty string`, `not a valid RFC 9110 token`, not on a `primitive-typed` property, and `not case-insensitively unique`.
3. Expect the exclusion on the client to be skipped over stdio. The client applies it only on a modern-era connection that is not stdio, so the same malformed tool stays visible to a stdio client and vanishes over Streamable HTTP. The server-side registration warning does not make that distinction and is the one to trust.
4. Clear the client's cached listing after you fix the schema. The exclusion is applied to the cached aggregated `tools/list` result as well as to each returned page, so a still-fresh cache entry keeps the tool missing; `cacheMode: 'refresh'` on the call forces a fetch.
5. Do not reach for `x-mcp-header` in a browser-resident client. The SDK skips mirroring there, because dynamically named headers cannot be statically allowlisted for credentialed CORS, and a conforming server then rejects the `tools/call` for the missing header. The SDK documents this as a known limitation rather than a bug.

```ts
// Rejected: the annotation is under `items`, so it is not statically
// reachable through a chain of `properties` keys.
inputSchema: {
  type: 'object',
  properties: {
    regions: {
      type: 'array',
      items: { type: 'string', 'x-mcp-header': 'Region' }
    }
  }
}

// Accepted: a primitive property reached through `properties` only.
inputSchema: {
  type: 'object',
  properties: {
    region: { type: 'string', 'x-mcp-header': 'Region' },
    query: { type: 'string' }
  },
  required: ['region', 'query']
}
```

[MCP TypeScript SDK: supporting 2026-07-28](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration/support-2026-07-28.md)

### MCP Python SDK

If the client dropping the tool is the MCP Python SDK 2.x

1. Search your logs for `dropping tool` rather than for the tool name. The client session logs `dropping tool '<name>': invalid x-mcp-header (<reason>)` at warning level as it filters the listing, and it also evicts any argument-to-header map it had cached for that tool from an earlier, valid listing.
2. Expect the Python client to drop the tool on stdio as well. It gates the filter on the negotiated protocol version alone, with no transport carve-out, so a tool that a TypeScript client still shows over stdio disappears here. That difference is a real one between the two SDKs, not a misconfiguration in your server.
3. Read the reason string for the property path. The validator reports the five cases distinctly: it says the annotation was "found at a schema position not reachable via a pure properties chain" for the unreachable case, and `is only permitted on integer/string/boolean properties` for a wrong type.
4. Check the annotation survived schema generation when you declare it through Pydantic. It travels as `json_schema_extra={"x-mcp-header": "Region"}` on the field, and a wrapper that rebuilds the schema — an optional field widened to `type: ["string", "null"]`, for instance — can move it somewhere the validator will not accept.

```python
from pydantic import Field
from typing import Annotated

@mcp.tool()
def execute_sql(
    region: Annotated[
        str,
        Field(json_schema_extra={"x-mcp-header": "Region"}),
    ],
    query: str,
) -> str:
    ...
```

[MCP Python SDK: v1 to v2 migration guide](https://py.sdk.modelcontextprotocol.io/migration/)

### A server you don't control

If the tool belongs to someone else's server and you only consume it

1. Confirm the annotation is the cause before you file anything. A tool present in the raw `tools/list` and absent from your client is the signature of this exclusion; a tool absent from both is a different problem entirely.
2. Send the maintainer the property path and the reason string. The SDK warnings name both, which is most of a bug report, and a server author who only ever tested over stdio may never have seen the warning — the TypeScript client skips the exclusion on stdio, so the tool works there.
3. Reach the tool over stdio in the meantime, if the server offers that transport and your client is the TypeScript SDK. The exclusion is scoped to non-stdio modern connections there. The Python client applies it on every transport, so this is not a general workaround.
4. Pin the connection to an earlier protocol revision as the other stopgap. The rule arrived in 2026-07-28, so a client that negotiates 2025-11-25 against a server that still serves it returns the tool unfiltered. Treat that as buying days, not as a resting place.
5. Do not expect a capability allowlist to bring it back. `mcp-capability-filter-inbound` and its equivalents decide which capabilities a route exposes; nothing in front of the client can restore a tool the client removed after reading a successful response.

[MCP specification: Streamable HTTP custom headers](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http)

### Something else

Whatever the SDK, the shape is the same: the schema is the fault and the client is the enforcement point, so nothing you change at the transport or the authorization layer will bring the tool back. Read the raw `tools/list` response to confirm the server still publishes the tool, then find every `x-mcp-header` in that tool's `inputSchema` and check each one against the five constraints — non-empty, RFC 9110 token, case-insensitively unique, on a `string`, `integer` or `boolean` property, and reachable through `properties` keys only. Fix the schema where you can, and expect a cached listing to keep the tool hidden until it is refreshed. Where the server is not yours, send the maintainer the property path from that `jq` output and treat stdio or an earlier negotiated revision as the stopgap. One thing to rule out before you start: a valid annotation still adds a header to every matching `tools/call`, so a signing or header-allowlisting layer that did not expect `Mcp-Param-*` fails separately and later.

## Why it happens

The 2026-07-28 revision lets a server mirror a tool argument into an `Mcp-Param-{Name}` HTTP header by annotating the parameter with `x-mcp-header`, so an intermediary can route on a value without parsing the body.

An invalid annotation is fatal to the tool rather than to the request: "Clients using the Streamable HTTP transport MUST reject tool definitions where any `x-mcp-header` value violates these constraints. Rejection means the client MUST exclude the invalid tool from the result of `tools/list`." That "ensures that a single malformed tool definition does not prevent other valid tools from being used."

Logging is only a SHOULD and nothing raises an error, so both sides behaved correctly and the sole trace is a warning on whichever stream the client logs to.

The rule is narrower than it looks. It is transport-scoped, because clients on other transports "MAY ignore `x-mcp-header` annotations entirely", and era-scoped, so the same server and client keep working together until they negotiate 2026-07-28.

### The exchange

The MCP client posts tools/list to the server over Streamable HTTP on a 2026-07-28 connection. The server returns 200 with all of its tools, including one whose inputSchema carries an x-mcp-header annotation on a property that is not statically reachable. The client scans the returned definitions and drops that tool from the result before returning it, logging a warning. The model is offered the remaining tools and never learns the dropped one exists.

**Participants:** Model (sees the tool list), MCP client (Streamable HTTP, 2026-07-28), MCP server

1. **MCP client → MCP server** — `POST /mcp` — `Mcp-Method: tools/list`
2. **MCP server → MCP client** — `200 OK` — `4 tools, one with x-mcp-header` _(response)_
3. **MCP client** (internal step) — scan x-mcp-header declarations — `annotation under items, not properties`
4. **MCP client → Model** — `3 tools` — `warning logged, no error raised` _(response; this step fails — the flow stops here)_

Notes:

- **Steps 1–2** — Both sides are conformant here. The server is entitled to publish the annotation and returns every tool it has, so a capture of this exchange shows nothing wrong.
- **Steps 3–4** — The exclusion happens inside the client, after a successful response. Nothing is sent back to the server, so server-side logs record a normal tools/list and the tool count the model was offered appears nowhere.

## Next steps to production

The headers that annotation was trying to populate exist so whatever sits in the request path can act on them, which is where the next questions land.

- **We route on a mirrored header. Can a client downgrade its way past that?** — `mcp_request_rejected` — Only if nothing checks the version. Rejections are recorded per request with a named reason, so a request arriving without the header validation the version implies is visible rather than silently trusted.
- **Which tools does this route publish at all, whatever a server lists?** — `mcp-capability-filter-inbound` — Exactly the ones you put on it: capabilities outside the route's allowlist are refused with `MethodNotFound`, so the exposed surface is a route property rather than an upstream one — and `accessControl` narrows it further per caller.
- **Can we meter the tool that header was naming the tenant for?** — `complex-rate-limit-inbound` — Yes — per tenant: named counters, each with its own limit and window, keyed on the authenticated caller, so one tenant's volume is bounded without a shared global bucket. An Enterprise policy.
- **A tool call failed on a header. Which caller, and on which tool?** — `capability_invocation` — Both, from the event stream: every call carries the caller, the capability and the outcome, so a header-validation failure resolves to a client and a tool rather than to a `400` in an access log. Arguments are not in there — request bodies are never logged.

All of these attach to one MCP route's `policies.inbound` — the same policy engine on the way in and on the way out. Your MCP server keeps the code and the authentication it has today. See [Zuplo MCP Gateway](/mcp-gateway).

## Keep reading

- **Similar error** — [`ModuleNotFoundError: No module named 'mcp.server.fastmcp'`](/learn/mcp/errors/no-module-named-mcp-server-fastmcp) — Your server code imports `mcp.server.fastmcp`, but the installed `mcp` package is 2.x, where that module no longer exists — so the process dies at import, before it speaks any MCP.
- **Similar error** — [`The request signature we calculated does not match the signature you provided`](/learn/mcp/errors/aws-signaturedoesnotmatch) — AWS recomputed the signature over the request it actually received, got a different value from the one you sent, and answered `403` before your MCP server ran — so nothing about your tools, your handler, or your IAM policy is implicated yet.
- **Error reference** — [every documented MCP error](/learn/mcp/errors)
