MCP tool-call validation rejects omission of any optional/defaulted parameter, regardless of type

Status Open
Maintainer reply None cached
Activity 4 comments · opened Sep 17, 2026

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

View original on GitHub ↗

4 Comments

AbarnaaSree · 28 days ago

I’d like to work on this issue.

I can first trace the MCP tool schema flow from tools/list through the schema transformation/deferred-tool layer to the final argument validator, with particular attention to how required, 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 = 10 and nullable defaulted parameters such as filter_type: str | None = None.

I’ll keep the change limited to preserving the existing MCP schema semantics and avoid changing server-side behavior.

m13v · 28 days ago

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.

naji247 · 27 days ago

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 inputSchema and lists them in required: session_id (with a pattern), agent_id, context. On 2026-09-18 one Claude Code 2.1.276 process (claude-code/2.1.276 (cli), one tools/list) made 13 tools/call requests 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 nonoptional error in this issue would have blocked the calls), and the model never saw their descriptions either, which suggests more than required is lost. Other clients in the same hour received the full schema, and 2.1.276 elsewhere sent session_id on 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, a pattern, and a long description on one property; check which survive to the model. Happy to share the exact schema or redacted request bodies.

m13v · 27 days ago

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.