MCP tool-call validation rejects omission of any optional/defaulted parameter, regardless of type
Summary
When calling an MCP tool whose JSON Schema declares an optional parameter (one with a default value), Claude Code's tool-call argument validator rejects the call if that parameter is omitted — even though the server's schema correctly marks it optional. The error is client-side, before the request reaches the MCP server:
Invalid arguments for tool <name>: [{"code":"invalid_type","expected":"nonoptional","path":["<param>"],"message":"Invalid input: expected nonoptional, received undefined"}]
Reproduction
A self-hosted FastMCP-based MCP server (Python, streamable-http) exposes:
def search(query: str, filter_type: str | None = None, page_size: int = 10, start_cursor: str | None = None) -> str: ...
FastMCP advertises page_size as {"type": "number", "default": 10} (plain, non-nullable) and filter_type/start_cursor as {"anyOf": [{"type": "string"}, {"type": "null"}], "default": null}. None appear in the top-level required array.
Calling the tool with only query set (letting the rest take their declared defaults) fails client-side with the error above for every omitted param — page_size included.
page_size is the key data point: it is not nullable and carries no anyOf union, so "the client strips anyOf unions" cannot explain this. It fails identically to the nullable string params, which points instead at every parameter carrying a default being validated as if .optional() was never applied to it — a .nullable()-without-.optional()-shaped gap — regardless of whether the field is actually nullable.
What I inspected
The tool definition loaded inside my session (surfaced via the tool-search/deferred-tool mechanism) has no required array at all, e.g. for a different tool on the same server:
{"properties": {"markdown": {"default": null}, "page_id": {"type": "string"}, "text": {"default": null}}, "type": "object"}
The upstream server's own advertised schema (verified independently via a local tools/list probe against the same server) correctly has "required": ["page_id"] and full type info (anyOf: [string, null]) on text/markdown. Something between the server's schema and whatever validates my tool calls drops the required array entirely and, for nullable fields, collapses the type info down to bare {"default": null}.
Impact
Any MCP server with an optional/defaulted parameter is affected, not just this one. A caller has no way to omit such a parameter — it must be supplied explicitly, with a real value, on every call, even when the intent is "just use the default." This defeats the purpose of an optional parameter and makes tool schemas misleading: they advertise optional, the runtime enforces required.
Workaround
None client-side. Server implementers can mitigate for string params by treating an empty string "" as "not provided," but this doesn't work for bool/int params (no safe empty sentinel without risking a real 0/false), and it's a per-server patch for a client-side defect.
Environment
- Claude Code (desktop app)
- MCP server: custom FastMCP-based server (Python), streamable-http transport
- Observed 2026-09-17
4 Comments
I’d like to work on this issue.
I can first trace the MCP tool schema flow from
tools/listthrough the schema transformation/deferred-tool layer to the final argument validator, with particular attention to howrequired,default,optional, and nullable fields are represented.If I can reproduce the issue on the current codebase, I’d like to add a focused fix and regression tests covering both non-nullable defaulted parameters such as
page_size: int = 10and nullable defaulted parameters such asfilter_type: str | None = None.I’ll keep the change limited to preserving the existing MCP schema semantics and avoid changing server-side behavior.
the empty-string sentinel you floated for the string params has its own trap. plenty of tools take '' as a real value: clearing a field, a deliberately blank note, an empty filter that means 'match all' rather than 'unset'. treat '' as 'not provided' and you silently swap the caller's intended empty for the default, and you can't tell those two apart any better than you can tell omitted from null right now. it's the same bug you're reporting, just pushed down a layer into every server that has to carry the workaround.
Same failure seen from the MCP server side, on the CLI rather than the desktop app.
Our hosted streamable-HTTP server (AgentCat, OAuth) injects three string parameters into every tool's
inputSchemaand lists them inrequired:session_id(with apattern),agent_id,context. On 2026-09-18 one Claude Code 2.1.276 process (claude-code/2.1.276 (cli), onetools/list) made 13tools/callrequests and none carried any of the three. Each call held only the arguments the tool description text implies. The same user on 2.1.268 a week earlier sent all three on every call.Two inferences: the client no longer saw them as required (otherwise the
expected nonoptionalerror in this issue would have blocked the calls), and the model never saw their descriptions either, which suggests more thanrequiredis lost. Other clients in the same hour received the full schema, and 2.1.276 elsewhere sentsession_idon 96% of calls, so this is per session, not per version. I could not reproduce it locally on 2.1.276 (fresh session, ToolSearch deferred load,--resume).Suggested test case: a tool with
required, apattern, and a longdescriptionon one property; check which survive to the model. Happy to share the exact schema or redacted request bodies.the suggested test reads schema survival off the tool_call bodies, but that conflates two failures: a model drops required args it did see, and it'll send ones whose description got stripped. your 96%/4% split is behavioral for the same reason. to separate schema-loss from model-choice you have to capture the resolved inputSchema at the deferred-load boundary, not infer it from what the calls carried.