{"slug": "my-mcp-tool-s-schema-lists-null-as-the-default-for-a-field-sending-null-was-the", "title": "My MCP Tool's Schema Lists null as the Default for a Field. Sending null Was the One Value It Rejected.", "summary": "A developer discovered that FastMCP, a popular Python framework for building Model Context Protocol servers, generates contradictory tool schemas for optional parameters, listing null as the default while rejecting null as an invalid input. The issue stems from pydantic validating bare type annotations strictly, so parameters like `title: str = None` accept only strings, causing valid calls that explicitly send the advertised default to fail with a generic validation error. The developer confirmed the bug across all five optional parameters on their server and proposed a one-character fix using `str | None` annotations.", "body_md": "I went looking for a fresh angle in my own MCP server this week, and I kept landing on the same functions I've already hardened three or four times — the duplicate-title guard, the confirm gate, the fingerprint check. All real fixes, all still holding. But every one of them was about what `update_article`\n\n*does* once a call reaches it. Nobody had ever looked at the layer in front of that: what FastMCP actually turns my Python function signature into, and whether a caller can even get a legitimate call through it.\n\n`update_article`\n\nlooks like this, trimmed to the signature:\n\n``` python\n@mcp.tool()\ndef update_article(article_id: int, title: str = None, body_markdown: str = None,\n                    published: bool = None, confirm: bool = False,\n                    expected_fingerprint: str = None) -> dict:\n```\n\nThe whole function is built around `None`\n\nmeaning \"don't touch this field\" — `if title is not None: article[\"title\"] = title`\n\n, repeated for each optional param. I've read this function probably a dozen times while fixing the confirm gate, the fingerprint staleness check, the duplicate-title check. I never once looked at what `mcp.tool()`\n\ndoes with `title: str = None`\n\nbefore the function body ever runs.\n\nFastMCP builds its tool schema — and its runtime argument validator — from a pydantic model it generates off the function's type annotations, not off what a human reading the signature would infer. I checked what it actually produced:\n\n```\ntools = await mcp.list_tools()\n\"title\": {\n  \"default\": null,\n  \"title\": \"Title\",\n  \"type\": \"string\"\n}\n```\n\nRead that literally: the schema says this field's default is `null`\n\n, and also says the only acceptable type is `\"string\"`\n\n. Those two lines contradict each other. `type: str`\n\nwith a `None`\n\ndefault is not the same thing as `Optional[str]`\n\nto pydantic — it takes the bare annotation at face value and validates against exactly that, regardless of what the default happens to be. The default only kicks in when the key is *absent* from the call entirely.\n\nSo I tried the thing the schema itself implies is fine — sending the advertised default explicitly:\n\n```\nawait mcp.call_tool(\"update_article\", {\"article_id\": 42, \"title\": None})\nToolError: Error executing tool update_article: 1 validation error for update_articleArguments\ntitle\n  Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]\n```\n\nThat's a real `mcp.call_tool()`\n\ncall through a real, installed `FastMCP`\n\ninstance — not a stub, not a hypothetical. Pydantic rejects it before `update_article`\n\n's own body, and its whole `if title is not None`\n\ndesign, ever runs. Only omitting the key works. I checked every other implicitly-optional parameter on this server the same way: `body_markdown`\n\n, `published`\n\n, `expected_fingerprint`\n\non `update_article`\n\n, and `tags`\n\non `create_article`\n\n, all five reproduce it, all five for the same reason — a bare non-Optional annotation with a `None`\n\ndefault.\n\nWhy this actually matters, and isn't just a pedantic type-checker complaint: an MCP client filling in a tool call isn't a human reading the function signature and knowing to omit unused keys. It's building JSON from a schema, and the schema it's reading says `\"default\": null`\n\nright on the field. An LLM deciding it doesn't want to change `title`\n\non this call has two equally reasonable ways to express that from the schema alone — leave the key out, or set it to the literal value the schema itself just told it was the default. One of those two reasonable readings throws. And when it throws, what the caller sees is a generic pydantic `ToolError`\n\nabout `string_type`\n\n, not this tool's own carefully-written error messages — the ones I spent three separate bug-log entries getting right for the confirm gate and the staleness check never even get a chance to run.\n\nThe fix is one character of intent per parameter, `str | None`\n\ninstead of `str`\n\n:\n\n``` python\ndef update_article(article_id: int, title: str | None = None, body_markdown: str | None = None,\n                    published: bool | None = None, confirm: bool = False,\n                    expected_fingerprint: str | None = None) -> dict:\n```\n\nSame change on `create_article`\n\n's `tags: list[str] | None = None`\n\n. The schema now tells the truth:\n\n```\n\"title\": {\n  \"anyOf\": [{\"type\": \"string\"}, {\"type\": \"null\"}],\n  \"default\": null,\n  \"title\": \"Title\"\n}\n```\n\nAnd the same call that threw now reaches the function body — `mcp.call_tool(\"update_article\", {\"article_id\": 42, \"title\": None})`\n\nnow fails with `update_article`\n\n's own `\"no fields to update\"`\n\n`ValueError`\n\n, the exact same outcome as omitting the key. That's the whole fix: making the two ways of saying \"don't change this\" actually equivalent, instead of one of them being an unhandled validation error.\n\nThe part that bothers me more than the bug itself is why every prior selftest pass on this file missed it. `server.py --selftest`\n\ncalls `update_article(42, title=\"new title\")`\n\ndirectly, as a Python function — every regression case added across five separate bug-log entries this month does the same. Calling the function directly skips FastMCP's pydantic layer entirely; there's no schema validation to fail because you're not going through the schema. The gap was invisible to every test in this file because none of them had ever gone through the actual MCP call path a real client uses. I added one that does — `asyncio.run()`\n\nover a real `mcp.call_tool()`\n\n, against the real installed `mcp`\n\npackage, not a stub — specifically because a stub built to make `--selftest`\n\nimportable without the dependency wouldn't reproduce pydantic's validation behavior at all, which is the entire subject of this bug.\n\nThe generalizable lesson: if your MCP tool has a parameter whose accepted-empty-value is `None`\n\n— used to represent \"no change,\" \"no filter,\" \"leave as-is\" — checking that the Python signature *runs* isn't the same as checking that the schema FastMCP derives from it can actually carry that value across the wire. `str = None`\n\nreads as optional to any human skimming the function. Pydantic reads it as `str`\n\n, full stop, and the schema it hands your caller will cheerfully advertise a default it won't accept. The only way I found this was by calling my own tool the way an actual MCP client does, not the way I've been calling it inside `--selftest`\n\nfor two months.", "url": "https://wpnews.pro/news/my-mcp-tool-s-schema-lists-null-as-the-default-for-a-field-sending-null-was-the", "canonical_source": "https://dev.to/enjoy_kumawat/my-mcp-tools-schema-lists-null-as-the-default-for-a-field-sending-null-was-the-one-value-it-kdl", "published_at": "2026-08-17 03:41:17+00:00", "updated_at": "2026-08-17 04:42:39.339861+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "ai-infrastructure"], "entities": ["FastMCP", "MCP", "pydantic"], "alternates": {"html": "https://wpnews.pro/news/my-mcp-tool-s-schema-lists-null-as-the-default-for-a-field-sending-null-was-the", "markdown": "https://wpnews.pro/news/my-mcp-tool-s-schema-lists-null-as-the-default-for-a-field-sending-null-was-the.md", "text": "https://wpnews.pro/news/my-mcp-tool-s-schema-lists-null-as-the-default-for-a-field-sending-null-was-the.txt", "jsonld": "https://wpnews.pro/news/my-mcp-tool-s-schema-lists-null-as-the-default-for-a-field-sending-null-was-the.jsonld"}}