Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/mcp/server/fastmcp/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
import json
from collections.abc import Awaitable, Callable, Sequence
from itertools import chain
from types import GenericAlias
from typing import Annotated, Any, ForwardRef, cast, get_args, get_origin, get_type_hints
from types import GenericAlias, UnionType
from typing import Annotated, Any, ForwardRef, Union, cast, get_args, get_origin, get_type_hints

import pydantic_core
from pydantic import (
Expand Down Expand Up @@ -232,6 +232,12 @@ def func_metadata(
WithJsonSchema({"title": param.name, "type": "string"}),
]

# if it's Optional[SomeType] or SomeType | None and has no explicit default, set default=None
origin = get_origin(annotation)
if (origin is Union or origin is UnionType) and type(None) in get_args(annotation):
if isinstance(param.default, FieldInfo) and param.default.default is PydanticUndefined:
param.default.default = None

field_info = FieldInfo.from_annotated_attribute(
_get_typed_annotation(annotation, globalns),
param.default if param.default is not inspect.Parameter.empty else PydanticUndefined,
Expand Down
51 changes: 50 additions & 1 deletion tests/server/fastmcp/test_func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# pyright: reportUnknownLambdaType=false
from collections.abc import Callable
from dataclasses import dataclass
from typing import Annotated, Any, TypedDict
from typing import Annotated, Any, Optional, TypedDict

import annotated_types
import pytest
Expand Down Expand Up @@ -1094,3 +1094,52 @@ def func_with_reserved_json(
assert result["json"] == {"nested": "data"}
assert result["model_dump"] == [1, 2, 3]
assert result["normal"] == "plain string"


def test_optional_parameters_not_required():
"""Test that Optional parameters are not marked as required in JSON schema."""

def func_with_optional_params(
required: str = Field(description="This should be required"),
optional: str | None = Field(description="This should be optional"),
optional_with_default: str | None = Field(default="hello", description="This should be optional with default"),
optional_with_union_type: Optional[str] = Field(description="This should be optional"), # noqa: UP045
optional_with_union_type_and_default: Optional[str] | None = Field( # noqa: UP045
default="hello", description="This should be optional with default"
),
) -> str:
return f"{required}|{optional}|{optional_with_default}|{optional_with_union_type}|{optional_with_union_type_and_default}"

meta = func_metadata(func_with_optional_params)
assert meta.arg_model.model_json_schema() == {
"properties": {
"required": {"description": "This should be required", "title": "Required", "type": "string"},
"optional": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"default": None,
"description": "This should be optional",
"title": "Optional",
},
"optional_with_default": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"default": "hello",
"description": "This should be optional with default",
"title": "Optional With Default",
},
"optional_with_union_type": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"default": None,
"description": "This should be optional",
"title": "Optional With Union Type",
},
"optional_with_union_type_and_default": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"default": "hello",
"description": "This should be optional with default",
"title": "Optional With Union Type And Default",
},
},
"required": ["required"],
"title": "func_with_optional_paramsArguments",
"type": "object",
}
Loading