Files
Pietro Zullo ccfb07a8ee fix(python): simplify tool JSON Schema for Optional parameters (#1141)
* fix(python/server): simplify tool JSON Schema for Optional parameters

Pydantic generates {"anyOf": [{"type": "T"}, {"type": "null"}]} for
Optional[T] tool parameters. While valid JSON Schema 2020-12, this
pattern breaks description rendering in the MCP Inspector UI and fails
Gemini API validation (requires top-level type field). MCP signals
optionality via the required array, making the anyOf/null wrapper
redundant.

- Add simplify_optional_schema() utility that collapses nullable anyOf
  into flat {"type": "T"} while preserving description, default, title
- Override MCPServer.add_tool() to apply simplification after Pydantic
  generates the schema
- Add 12 unit tests for the schema simplifier (test_json_schema.py)
- Add 23 unit tests for tool schema output (test_list_tool_format.py)
- Update server_example.py with representative parameter patterns
  including Literal enum types

* docs(python/server): add parameter types and JSON Schema section to tools docs

Document the Python-to-JSON-Schema type mapping, required/optional/nullable
parameter patterns, and Literal enum usage. Includes a note explaining the
anyOf/null simplification that mcp-use applies automatically.

* fix(python/server): address PR review comments on json_schema.py

- Fix _simplify_property docstring: says "in place" but returns new dict
- Fix simplify_optional_schema docstring: document fast-path return when
  no properties exist (returns original, not a copy)
- Wrap dict comprehension for line length

* style: format server_example.py

* docs: regenerate API reference for json_schema module
2026-03-05 09:24:46 +01:00

95 lines
3.2 KiB
Python

"""Utilities for producing MCP-compliant JSON Schemas.
Pydantic represents ``Optional[T]`` as ``{"anyOf": [{"type": "T"}, {"type":
"null"}]}``. While valid JSON Schema, this is not idiomatic MCP — the protocol
signals optionality by omitting the property from the ``required`` array. The
``anyOf``/null pattern also confuses several MCP clients (e.g. the Inspector)
which fail to render descriptions for those fields.
``simplify_optional_schema`` walks a JSON Schema object and collapses every
nullable ``anyOf`` into the simpler ``{"type": "T"}`` form.
"""
from __future__ import annotations
from copy import deepcopy
from typing import Any
# Keys that live at the property level and must be preserved when we
# replace the ``anyOf`` wrapper with the inner type schema.
_PROPERTY_KEYS = ("description", "default", "title", "examples")
_NULL_SCHEMA = {"type": "null"}
def _is_nullable_any_of(any_of: list[dict[str, Any]]) -> dict[str, Any] | None:
"""Return the non-null branch if *any_of* is exactly ``[<type>, null]``.
Returns ``None`` when the pattern is more complex (``$ref`` schemas,
>2 entries, …) so the caller can leave it untouched.
"""
if len(any_of) != 2:
return None
first, second = any_of
if second == _NULL_SCHEMA:
base = first
elif first == _NULL_SCHEMA:
base = second
else:
return None
# ``$ref`` schemas resolve to definitions elsewhere in the document —
# stripping the ``anyOf`` wrapper would lose the nullable semantics.
if "$ref" in base:
return None
return base
def _simplify_property(prop: dict[str, Any]) -> dict[str, Any]:
"""Return a simplified version of a single property schema.
If *prop* contains a nullable ``anyOf`` of the form ``[<type>, null]``,
returns a new dict based on the non-null branch with metadata preserved
from the outer property. For all other schemas the original *prop* dict
is returned unchanged. The input is never mutated.
"""
any_of = prop.get("anyOf")
if any_of is None:
return prop
base_type = _is_nullable_any_of(any_of)
if base_type is None:
# Complex anyOf — leave untouched
return prop
# Build the simplified property: start from the base type, overlay
# any metadata that Pydantic placed at the outer level.
simplified = dict(base_type)
for key in _PROPERTY_KEYS:
if key in prop:
simplified[key] = prop[key]
return simplified
def simplify_optional_schema(schema: dict[str, Any]) -> dict[str, Any]:
"""Return *schema* with nullable ``anyOf`` patterns simplified.
Returns a deep copy when properties need simplification. When the schema
has no ``properties`` key (or it is empty), the original dict is returned
as-is since there is nothing to transform.
Only touches ``properties`` of the top-level object schema — nested
``$defs`` and deeply nested objects are left as-is since MCP tool schemas
are typically flat.
"""
properties = schema.get("properties")
if not properties:
return schema
schema = deepcopy(schema)
schema["properties"] = {name: _simplify_property(prop) for name, prop in schema["properties"].items()}
return schema