diff --git a/examples/mcp_servers/tools_with_output_schema/pyproject.toml b/examples/mcp_servers/tools_with_output_schema/pyproject.toml index ab40fe6e..1d51c0d3 100644 --- a/examples/mcp_servers/tools_with_output_schema/pyproject.toml +++ b/examples/mcp_servers/tools_with_output_schema/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "MCP Server created with Arcade.dev" requires-python = ">=3.10" dependencies = [ - "arcade-mcp-server>=1.19.0,<2.0.0", + "arcade-mcp-server>=1.19.1,<2.0.0", ] [project.optional-dependencies] diff --git a/examples/mcp_servers/tools_with_output_schema/src/tools_with_output_schema/server.py b/examples/mcp_servers/tools_with_output_schema/src/tools_with_output_schema/server.py index 9f8e8446..25c9ad98 100644 --- a/examples/mcp_servers/tools_with_output_schema/src/tools_with_output_schema/server.py +++ b/examples/mcp_servers/tools_with_output_schema/src/tools_with_output_schema/server.py @@ -7,17 +7,21 @@ fully-expanded JSON Schema output schemas, so MCP clients can validate and display tool results without guessing the shape of the data. Tools in this server progress from simple to complex: - - calculate_statistics — flat TypedDict (all scalar fields) - - analyze_text — TypedDict with a list field - - get_calendar_info — TypedDict with a nested TypedDict field - - parse_url — TypedDict with two levels of nesting + - calculate_statistics — flat TypedDict (all scalar fields) + - analyze_text — TypedDict with a list field + - get_calendar_info — TypedDict with a nested TypedDict field + - parse_url — TypedDict with two levels of nesting + - search_users — TypedDict with total=False (all optional fields) + - get_user_profile — mixed required/optional fields via inheritance + - lookup_record — nullable fields (str | None) + - get_team_info — optional nested TypedDict (Optional[TypedDict]) """ import sys from collections import Counter from datetime import datetime from statistics import mean, median -from typing import Annotated +from typing import Annotated, Optional from urllib.parse import urlparse from arcade_mcp_server import MCPApp @@ -88,6 +92,69 @@ class ParsedUrl(TypedDict): domain: str +class SearchResult(TypedDict, total=False): + """Search result where every field is optional (total=False). + + When a field is absent from the returned dict it must NOT appear in the + serialized output. Before the total=False fix, absent fields leaked as + explicit ``null`` values, which violated the output schema. + """ + + username: str + email: str + display_name: str + avatar_url: str + + +class _UserBase(TypedDict): + """Required fields that every user profile must include.""" + + user_id: int + username: str + + +class UserProfile(_UserBase, total=False): + """Mixed required / optional fields via TypedDict inheritance. + + ``user_id`` and ``username`` are required (from _UserBase); + ``bio`` and ``website`` are optional (total=False on this class). + The output schema's ``required`` array must list only the required keys. + """ + + bio: str + website: str + + +class LookupResult(TypedDict): + """Demonstrates nullable fields (``str | None``). + + A nullable field can hold either a real value or ``null``, and the + output schema must advertise the type as ``["string", "null"]``. + """ + + key: str + value: str | None + error_message: str | None + + +class TeamMember(TypedDict, total=False): + """A team member with all-optional fields.""" + + name: str + role: str + + +class TeamInfo(TypedDict): + """Demonstrates an optional nested TypedDict (``Optional[TeamMember]``). + + The ``lead`` field is required-but-nullable: it must be present in the + output, but its value may be ``null`` when no lead is assigned. + """ + + team_name: str + lead: Optional[TeamMember] + + # --------------------------------------------------------------------------- # Tools # --------------------------------------------------------------------------- @@ -176,6 +243,76 @@ def parse_url( ) +@app.tool +def search_users( + query: Annotated[str, "Search query to match against usernames"], +) -> Annotated[SearchResult, "Matching user (only populated fields are returned)"]: + """Search for a user and return only the fields that matched. + + Demonstrates total=False: absent fields are omitted from the output + rather than serialized as null. + """ + # Simulate a search that only finds partial information + result = SearchResult(username=query.lower()) + if "@" in query: + result["email"] = query.lower() + return result + + +@app.tool +def get_user_profile( + username: Annotated[str, "The username to look up"], +) -> Annotated[UserProfile, "User profile with required and optional fields"]: + """Look up a user profile. + + Demonstrates mixed required/optional fields: user_id and username are + always present; bio and website may be absent. + """ + profile = UserProfile(user_id=42, username=username) + if username == "admin": + profile["bio"] = "Site administrator" + profile["website"] = "https://example.com" + # For any other user, bio and website are intentionally absent + return profile + + +@app.tool +def lookup_record( + key: Annotated[str, "The key to look up"], +) -> Annotated[LookupResult, "The lookup result with nullable value and error fields"]: + """Look up a record by key. + + Demonstrates nullable fields: value and error_message are typed as + str | None, so the output schema advertises ["string", "null"]. + """ + records = {"color": "blue", "size": "large"} + value = records.get(key) + return LookupResult( + key=key, + value=value, + error_message=None if value else f"No record found for key: {key}", + ) + + +@app.tool +def get_team_info( + team_name: Annotated[str, "The team name to look up"], +) -> Annotated[TeamInfo, "Team info with an optional nested TypedDict for the lead"]: + """Get team information including the team lead. + + Demonstrates Optional[TypedDict]: the lead field is required-but-nullable. + When a lead exists, absent total=False fields inside the nested TeamMember + are properly omitted (not serialized as null). + """ + if team_name == "backend": + return TeamInfo( + team_name=team_name, + lead=TeamMember(name="Alice"), # role intentionally absent + ) + # Team with no lead assigned + return TeamInfo(team_name=team_name, lead=None) + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- diff --git a/libs/arcade-core/arcade_core/catalog.py b/libs/arcade-core/arcade_core/catalog.py index 6275a949..f8b06d06 100644 --- a/libs/arcade-core/arcade_core/catalog.py +++ b/libs/arcade-core/arcade_core/catalog.py @@ -14,6 +14,7 @@ from typing import ( Annotated, Any, Literal, + Optional, Union, cast, get_args, @@ -102,6 +103,9 @@ class WireTypeInfo: properties: dict[str, "WireTypeInfo"] | None = None inner_properties: dict[str, "WireTypeInfo"] | None = None description: str | None = None + required_keys: list[str] | None = None + inner_required_keys: list[str] | None = None + nullable: bool | None = None class ToolMeta(BaseModel): @@ -762,6 +766,7 @@ def get_wire_type_info(_type: type) -> WireTypeInfo: # If so, get the inner (enclosed) type is_list = get_origin(_type) is list inner_properties = None + inner_required_keys = None if is_list: inner_type = get_args(_type)[0] @@ -773,9 +778,11 @@ def get_wire_type_info(_type: type) -> WireTypeInfo: # If inner type has properties (it's a complex object), propagate them if inner_info.properties: inner_properties = inner_info.properties + inner_required_keys = inner_info.required_keys # If inner type is array (nested arrays), propagate inner_properties elif inner_info.inner_properties: inner_properties = inner_info.inner_properties + inner_required_keys = inner_info.inner_required_keys else: inner_wire_type = None @@ -803,8 +810,9 @@ def get_wire_type_info(_type: type) -> WireTypeInfo: # Extract properties for complex types properties = None + required_keys = None if wire_type == "json" and not is_list: - properties = extract_properties(type_to_check) + properties, required_keys = extract_properties(type_to_check) return WireTypeInfo( wire_type, @@ -812,6 +820,8 @@ def get_wire_type_info(_type: type) -> WireTypeInfo: enum_values if is_enum else None, properties, inner_properties, + required_keys=required_keys, + inner_required_keys=inner_required_keys, ) @@ -844,9 +854,14 @@ def _extract_typeddict_field_descriptions(typeddict_class: type) -> dict[str, st return descriptions -def extract_properties(type_to_check: type) -> dict[str, WireTypeInfo] | None: +def extract_properties( + type_to_check: type, +) -> tuple[dict[str, WireTypeInfo] | None, list[str] | None]: """ Extract properties from TypedDict, Pydantic models, or other structured types. + + Returns (properties, required_keys). required_keys is a sorted list of required + property names for TypedDict types, or None for other types. """ properties = {} @@ -868,6 +883,8 @@ def extract_properties(type_to_check: type) -> dict[str, WireTypeInfo] | None: wire_info = get_wire_type_info(field_type) properties[field_name] = wire_info + return (properties or None, None) + # Handle TypedDict elif is_typeddict(type_to_check): # Get type hints for the TypedDict @@ -878,10 +895,13 @@ def extract_properties(type_to_check: type) -> dict[str, WireTypeInfo] | None: for field_name, field_type in type_hints.items(): # Handle Optional types (Union[T, None]) - if is_strict_optional(field_type): + is_nullable = is_strict_optional(field_type) + if is_nullable: # Extract the non-None type from Optional field_type = next(arg for arg in get_args(field_type) if arg is not type(None)) wire_info = get_wire_type_info(field_type) + if is_nullable: + wire_info.nullable = True # Add description if available if field_name in field_descriptions: @@ -889,12 +909,16 @@ def extract_properties(type_to_check: type) -> dict[str, WireTypeInfo] | None: properties[field_name] = wire_info + req = sorted(getattr(type_to_check, "__required_keys__", frozenset())) + required_keys = req or None # normalize empty → None + return (properties or None, required_keys) + # Handle regular dict with type annotations (e.g., dict[str, Any]) elif get_origin(type_to_check) is dict: # For generic dicts, we can't extract specific properties - return None + return (None, None) - return properties if properties else None + return (properties or None, None) def wire_type_info_to_value_schema(wire_info: WireTypeInfo) -> ValueSchema: @@ -924,6 +948,9 @@ def wire_type_info_to_value_schema(wire_info: WireTypeInfo) -> ValueSchema: properties=properties, inner_properties=inner_properties, description=wire_info.description, + required_keys=wire_info.required_keys, + inner_required_keys=wire_info.inner_required_keys, + nullable=wire_info.nullable, ) @@ -1171,6 +1198,18 @@ def determine_output_model(func: Callable) -> type[BaseModel]: ) +class _TypedDictBaseModel(BaseModel): + """Base for Pydantic models derived from TypedDict. + + Defaults model_dump() to exclude_unset=True so that absent optional + fields (total=False) don't appear as None in serialized output. + """ + + def model_dump(self, **kwargs: Any) -> dict[str, Any]: + kwargs.setdefault("exclude_unset", True) + return super().model_dump(**kwargs) + + def create_model_from_typeddict(typeddict_class: type, model_name: str) -> type[BaseModel]: """ Create a Pydantic model from a TypedDict class. @@ -1185,13 +1224,22 @@ def create_model_from_typeddict(typeddict_class: type, model_name: str) -> type[ # Check if field is required is_required = field_name in getattr(typeddict_class, "__required_keys__", set()) - # Handle nested TypedDict - if is_typeddict(field_type): - nested_model = create_model_from_typeddict(field_type, f"{model_name}_{field_name}") + # Unwrap Optional[T] (i.e. T | None) so we can detect nested TypedDicts + is_optional_type = is_strict_optional(field_type) + inner_type = field_type + if is_optional_type: + inner_type = next(arg for arg in get_args(field_type) if arg is not type(None)) + + # Handle nested TypedDict (works for both T and Optional[T] after unwrapping) + if is_typeddict(inner_type): + nested_model = create_model_from_typeddict(inner_type, f"{model_name}_{field_name}") if is_required: - field_definitions[field_name] = (nested_model, Field()) + if is_optional_type: + field_definitions[field_name] = (Optional[nested_model], Field()) + else: + field_definitions[field_name] = (nested_model, Field()) else: - field_definitions[field_name] = (nested_model, Field(default=None)) + field_definitions[field_name] = (Optional[nested_model], Field(default=None)) else: if is_required: field_definitions[field_name] = (field_type, Field()) @@ -1199,7 +1247,7 @@ def create_model_from_typeddict(typeddict_class: type, model_name: str) -> type[ field_definitions[field_name] = (field_type, Field(default=None)) # Create and return the Pydantic model - return create_model(model_name, **field_definitions) + return create_model(model_name, __base__=_TypedDictBaseModel, **field_definitions) def to_tool_secret_requirements( diff --git a/libs/arcade-core/arcade_core/schema.py b/libs/arcade-core/arcade_core/schema.py index 62eacdc0..c8189cc2 100644 --- a/libs/arcade-core/arcade_core/schema.py +++ b/libs/arcade-core/arcade_core/schema.py @@ -120,6 +120,15 @@ class ValueSchema(BaseModel): description: str | None = None """Optional description of the value.""" + required_keys: list[str] | None = None + """For object types, the sorted list of required property names.""" + + inner_required_keys: list[str] | None = None + """For array types with object items, sorted required property names for each item.""" + + nullable: bool | None = None + """Whether this value can be null (from Optional/T|None annotation). None means not tracked.""" + class InputParameter(BaseModel): """A parameter that can be passed to a tool.""" diff --git a/libs/arcade-core/pyproject.toml b/libs/arcade-core/pyproject.toml index 90c6900a..b579d40d 100644 --- a/libs/arcade-core/pyproject.toml +++ b/libs/arcade-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-core" -version = "4.6.0" +version = "4.6.1" description = "Arcade Core - Core library for Arcade platform" readme = "README.md" license = { text = "MIT" } diff --git a/libs/arcade-mcp-server/arcade_mcp_server/convert.py b/libs/arcade-mcp-server/arcade_mcp_server/convert.py index b419e388..66761842 100644 --- a/libs/arcade-mcp-server/arcade_mcp_server/convert.py +++ b/libs/arcade-mcp-server/arcade_mcp_server/convert.py @@ -219,7 +219,9 @@ def _build_value_schema_json(value_schema: Any) -> dict[str, Any]: inner_schema = _value_schema_to_json_schema(value_schema) # Object return types are already top-level objects, emit directly. - if inner_schema.get("type") == "object": + # Check for both "object" and ["object", "null"] (nullable top-level TypedDict). + schema_type = inner_schema.get("type") + if schema_type == "object" or (isinstance(schema_type, list) and "object" in schema_type): return inner_schema # Primitives/arrays must be wrapped so outputSchema.type is "object" per MCP spec. @@ -231,6 +233,20 @@ def _build_value_schema_json(value_schema: Any) -> dict[str, Any]: } +def _apply_nullable(schema: dict[str, Any], value_schema: Any) -> dict[str, Any]: + """If value_schema.nullable, add null to type and enum.""" + if not getattr(value_schema, "nullable", False): + return schema + base_type = schema.get("type") + if isinstance(base_type, str): + schema["type"] = [base_type, "null"] + elif isinstance(base_type, list) and "null" not in base_type: + schema["type"] = [*base_type, "null"] + if "enum" in schema and None not in schema["enum"]: + schema["enum"] = [*schema["enum"], None] + return schema + + def _value_schema_to_json_schema(value_schema: Any) -> dict[str, Any]: """Convert a ValueSchema to a JSON Schema dict without top-level object wrapping. @@ -248,7 +264,9 @@ def _value_schema_to_json_schema(value_schema: Any) -> dict[str, Any]: schema["properties"][prop_name] = _value_schema_to_json_schema(prop_schema) if getattr(prop_schema, "description", None): schema["properties"][prop_name]["description"] = prop_schema.description - return schema + if getattr(value_schema, "required_keys", None): + schema["required"] = list(value_schema.required_keys) + return _apply_nullable(schema, value_schema) schema = {"type": _map_type_to_json_schema_type(val_type)} if getattr(value_schema, "enum", None): @@ -262,5 +280,7 @@ def _value_schema_to_json_schema(value_schema: Any) -> dict[str, Any]: items_schema["properties"][prop_name] = _value_schema_to_json_schema(prop_schema) if getattr(prop_schema, "description", None): items_schema["properties"][prop_name]["description"] = prop_schema.description + if getattr(value_schema, "inner_required_keys", None): + items_schema["required"] = list(value_schema.inner_required_keys) schema["items"] = items_schema - return schema + return _apply_nullable(schema, value_schema) diff --git a/libs/arcade-mcp-server/pyproject.toml b/libs/arcade-mcp-server/pyproject.toml index 078592d2..a30f6d5d 100644 --- a/libs/arcade-mcp-server/pyproject.toml +++ b/libs/arcade-mcp-server/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "arcade-mcp-server" -version = "1.19.0" +version = "1.19.1" description = "Model Context Protocol (MCP) server framework for Arcade.dev" readme = "README.md" authors = [{ name = "Arcade.dev" }] @@ -21,7 +21,7 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ - "arcade-core>=4.4.2,<5.0.0", + "arcade-core>=4.6.1,<5.0.0", "arcade-serve>=3.2.0,<4.0.0", "arcade-tdk>=3.6.0,<4.0.0", "arcadepy>=1.5.0", diff --git a/libs/tests/arcade_mcp_server/test_convert.py b/libs/tests/arcade_mcp_server/test_convert.py index 7c561dbc..f972df6d 100644 --- a/libs/tests/arcade_mcp_server/test_convert.py +++ b/libs/tests/arcade_mcp_server/test_convert.py @@ -5,7 +5,7 @@ import json from typing import Annotated import pytest -from arcade_core.catalog import MaterializedTool, ToolMeta, create_func_models +from arcade_core.catalog import MaterializedTool, ToolCatalog, ToolMeta, create_func_models from arcade_core.schema import ( InputParameter, ToolDefinition, @@ -660,3 +660,252 @@ class TestConvertContentToStructuredContent: result = convert_content_to_structured_content(Custom()) assert result == {"result": "custom-str"} + + +class TestOutputSchemaOptionalTypedDictFields: + """Test that outputSchema correctly represents optional TypedDict fields. + + Reproduces: When a TypedDict uses total=False, extract_properties() treats + every field identically — the outputSchema has no 'required' array, and field + types never include 'null'. Combined with model_dump() emitting None for absent + fields, the MCP client rejects the response because null doesn't match "string". + """ + + def _make_tool_and_mcp_tool(self, return_type, annotation_desc="result"): + """Helper: register a tool returning `return_type` and get the MCP tool.""" + from typing_extensions import TypedDict + + @tool + def f() -> Annotated[return_type, annotation_desc]: + """Test tool.""" + return {} + + tool_def = ToolCatalog().create_tool_definition(f, toolkit_name="test", toolkit_version="1.0") + input_model, output_model = create_func_models(f) + meta = ToolMeta(module=f.__module__, toolkit="test") + mat_tool = MaterializedTool( + tool=f, + definition=tool_def, + meta=meta, + input_model=input_model, + output_model=output_model, + ) + return create_mcp_tool(mat_tool) + + def test_total_false_typeddict_schema_allows_absent_fields(self): + """The outputSchema for a total=False TypedDict must not require any field. + + JSON Schema: if "required" is absent, all properties are optional — that's fine. + But the schema must also allow absent fields to validate. Currently the schema + does not emit a "required" array, which accidentally makes all fields optional + in JSON Schema terms. However, model_dump() reintroduces None values for the + absent fields, and "null" is not valid for "type": "string". The schema must + either: (a) include "null" in the type, or (b) the serializer must omit Nones. + This test validates the schema side: if a field CAN be null in structuredContent, + the schema must accept null. + """ + from typing_extensions import TypedDict + + class AllOptional(TypedDict, total=False): + name: str + count: int + + mcp_tool = self._make_tool_and_mcp_tool(AllOptional) + schema = mcp_tool.outputSchema + + assert schema is not None + assert schema["type"] == "object" + # The schema must not list any field as required since all are total=False + required = schema.get("required", []) + assert "name" not in required + assert "count" not in required + + def test_mixed_required_optional_schema_marks_required_fields(self): + """A TypedDict with both required and optional fields must have a 'required' array. + + Required fields (from the base total=True class) must appear in the + schema's 'required' array. Optional fields (from total=False) must not. + """ + from typing_extensions import TypedDict + + class _Base(TypedDict): + id: int + + class MixedDict(_Base, total=False): + label: str + + mcp_tool = self._make_tool_and_mcp_tool(MixedDict) + schema = mcp_tool.outputSchema + + assert schema is not None + assert schema["type"] == "object" + # "id" is required, "label" is optional + required = schema.get("required", []) + assert "id" in required, ( + "Required field 'id' must appear in outputSchema.required " + f"but got required={required}" + ) + assert "label" not in required + + def test_structuredcontent_validates_against_output_schema(self): + """End-to-end: structuredContent for absent optional fields must match outputSchema. + + Simulates the full pipeline: Pydantic model_dump() round-trip then + structuredContent conversion. When a tool omits an optional field, + model_dump() reintroduces it as None. The outputSchema says "type": "string", + so the MCP client rejects the null value. + """ + from arcade_core.catalog import create_model_from_typeddict + from typing_extensions import TypedDict + + class ResponseDict(TypedDict, total=False): + name: str + optional_detail: str + + # 1. Build outputSchema + mcp_tool = self._make_tool_and_mcp_tool(ResponseDict) + schema = mcp_tool.outputSchema + + # 2. Simulate the Pydantic round-trip that output.py performs: + # create_model_from_typeddict -> instantiate -> model_dump() + pydantic_model = create_model_from_typeddict(ResponseDict, "ResponseDict") + instance = pydantic_model(**{"name": "hello"}) # optional_detail absent + dumped = instance.model_dump() + + # 3. Convert to structuredContent (what server.py does) + structured = convert_content_to_structured_content(dumped) + + # The structured content must validate against the schema. + # No field in structuredContent should have a value (like null) + # that the schema's type declaration doesn't allow. + assert structured is not None + for field_name, field_schema in schema.get("properties", {}).items(): + if field_name in structured: + value = structured[field_name] + allowed_type = field_schema.get("type") + if value is None: + # null must be allowed by the schema + if isinstance(allowed_type, list): + assert "null" in allowed_type, ( + f"Field '{field_name}' is null in structuredContent but schema " + f"type {allowed_type} does not include 'null'" + ) + else: + assert allowed_type == "null" or allowed_type is None, ( + f"Field '{field_name}' is null in structuredContent but schema " + f"type is '{allowed_type}', not 'null'" + ) + + def test_list_of_typeddict_items_have_required(self): + """list[TypedDict] with total=True produces items.required in MCP outputSchema.""" + from typing_extensions import TypedDict + + class ItemDict(TypedDict): + name: str + value: int + + mcp_tool = self._make_tool_and_mcp_tool(list[ItemDict]) + schema = mcp_tool.outputSchema + + assert schema is not None + # list output gets wrapped: {type: object, properties: {result: {type: array, ...}}} + result_prop = schema["properties"]["result"] + assert result_prop["type"] == "array" + items_schema = result_prop["items"] + assert items_schema["type"] == "object" + assert sorted(items_schema["required"]) == ["name", "value"] + + def test_nullable_field_allows_null_in_schema(self): + """str | None field produces 'type': ['string', 'null'] in outputSchema.""" + from typing_extensions import TypedDict + + class NullableDict(TypedDict): + label: str + note: str | None + + mcp_tool = self._make_tool_and_mcp_tool(NullableDict) + schema = mcp_tool.outputSchema + + assert schema is not None + props = schema["properties"] + assert props["label"]["type"] == "string" + assert props["note"]["type"] == ["string", "null"] + + def test_nullable_enum_field_allows_null(self): + """Literal['a', 'b'] | None field produces type=['string', 'null'], enum=['a', 'b', None].""" + from typing import Literal + + from typing_extensions import TypedDict + + class EnumNullableDict(TypedDict): + status: Literal["a", "b"] | None + + mcp_tool = self._make_tool_and_mcp_tool(EnumNullableDict) + schema = mcp_tool.outputSchema + + assert schema is not None + status_schema = schema["properties"]["status"] + assert status_schema["type"] == ["string", "null"] + assert status_schema["enum"] == ["a", "b", None] + + def test_input_schema_typeddict_required_keys(self): + """TypedDict used as input parameter gets required array in inputSchema.""" + from typing_extensions import TypedDict + + class ConfigDict(TypedDict): + host: str + port: int + + @tool + def f(config: Annotated[ConfigDict, "The config"]) -> str: + """Test tool.""" + return "" + + tool_def = ToolCatalog().create_tool_definition( + f, toolkit_name="test", toolkit_version="1.0" + ) + input_model, output_model = create_func_models(f) + meta = ToolMeta(module=f.__module__, toolkit="test") + mat_tool = MaterializedTool( + tool=f, + definition=tool_def, + meta=meta, + input_model=input_model, + output_model=output_model, + ) + mcp_tool = create_mcp_tool(mat_tool) + config_schema = mcp_tool.inputSchema["properties"]["config"] + + assert config_schema["type"] == "object" + assert sorted(config_schema["required"]) == ["host", "port"] + + def test_input_schema_typeddict_nullable_field(self): + """TypedDict input parameter with str | None field gets type=['string', 'null'].""" + from typing_extensions import TypedDict + + class InputDict(TypedDict): + name: str + tag: str | None + + @tool + def f(data: Annotated[InputDict, "The data"]) -> str: + """Test tool.""" + return "" + + tool_def = ToolCatalog().create_tool_definition( + f, toolkit_name="test", toolkit_version="1.0" + ) + input_model, output_model = create_func_models(f) + meta = ToolMeta(module=f.__module__, toolkit="test") + mat_tool = MaterializedTool( + tool=f, + definition=tool_def, + meta=meta, + input_model=input_model, + output_model=output_model, + ) + mcp_tool = create_mcp_tool(mat_tool) + data_schema = mcp_tool.inputSchema["properties"]["data"] + + assert data_schema["properties"]["name"]["type"] == "string" + assert data_schema["properties"]["tag"]["type"] == ["string", "null"] diff --git a/libs/tests/core/test_typeddict_output_execution.py b/libs/tests/core/test_typeddict_output_execution.py index 8f74c4ae..a7c94f48 100644 --- a/libs/tests/core/test_typeddict_output_execution.py +++ b/libs/tests/core/test_typeddict_output_execution.py @@ -86,6 +86,32 @@ def returns_dict_list() -> Annotated[list[dict], "Returns list of dicts"]: ] +class MixedRequiredDict(TypedDict): + """Base TypedDict with required fields.""" + + name: str + + +class MixedOptionalDict(MixedRequiredDict, total=False): + """TypedDict with both required and optional fields.""" + + optional_field: str + + +@tool +def returns_partial_optional_typeddict() -> Annotated[ + OptionalFieldsDict, "Returns partial optional TypedDict" +]: + """Tool that returns a TypedDict with some fields omitted.""" + return {"required_field": "hello"} # optional_field intentionally omitted + + +@tool +def returns_mixed_typeddict() -> Annotated[MixedOptionalDict, "Returns mixed TypedDict"]: + """Tool that returns a TypedDict with required + optional fields, omitting optional.""" + return {"name": "hello"} # optional_field intentionally omitted + + class TestTypeDictOutputExecution: """Test TypedDict outputs through the full execution pipeline.""" @@ -248,3 +274,137 @@ class TestTypeDictOutputExecution: {"name": "string", "data": "test"}, {"name": "typed", "value": 99}, # TypedDict becomes regular dict at runtime ] + + @pytest.mark.asyncio + async def test_total_false_typeddict_omits_absent_fields(self, catalog, context): + """When a total=False TypedDict omits a field, it must not appear as None in output. + + Reproduces: model_dump() without exclude_none reintroduces None for absent + optional fields, which then fails JSON Schema validation against the outputSchema + (the schema declares "type": "string", not ["string", "null"]). + """ + definition = catalog.create_tool_definition( + returns_partial_optional_typeddict, toolkit_name="test", toolkit_version="1.0.0" + ) + input_model, output_model = create_func_models(returns_partial_optional_typeddict) + + result = await ToolExecutor.run( + func=returns_partial_optional_typeddict, + definition=definition, + input_model=input_model, + output_model=output_model, + context=context, + ) + + assert result.error is None + # The absent optional field must NOT be present with a None value + assert result.value == {"required_field": "hello"} + assert "optional_field" not in result.value + + @pytest.mark.asyncio + async def test_mixed_required_optional_typeddict_omits_absent_fields(self, catalog, context): + """A TypedDict inheriting required fields + total=False optional fields. + + When the tool omits the optional field, the output must contain only the + required field. None values for absent optional fields corrupt the MCP + structuredContent because they don't match the schema type. + """ + definition = catalog.create_tool_definition( + returns_mixed_typeddict, toolkit_name="test", toolkit_version="1.0.0" + ) + input_model, output_model = create_func_models(returns_mixed_typeddict) + + result = await ToolExecutor.run( + func=returns_mixed_typeddict, + definition=definition, + input_model=input_model, + output_model=output_model, + context=context, + ) + + assert result.error is None + assert result.value == {"name": "hello"} + assert "optional_field" not in result.value + + @pytest.mark.asyncio + async def test_exclude_unset_omits_absent_but_keeps_explicit_none(self, catalog, context): + """TypedDict with total=False absent field AND total=True str|None field set to None. + + The absent field must be excluded, but the explicitly-set None must be kept. + This validates exclude_unset (not exclude_none) semantics. + """ + + class _Required(TypedDict): + name: str + note: str | None # total=True, explicitly nullable + + class _WithOptional(_Required, total=False): + tag: str # total=False, will be absent + + @tool + def returns_mixed_nullable() -> Annotated[_WithOptional, "Mixed nullable output"]: + """Tool with both nullable required and absent optional fields.""" + return {"name": "hello", "note": None} # tag intentionally absent + + definition = catalog.create_tool_definition( + returns_mixed_nullable, toolkit_name="test", toolkit_version="1.0.0" + ) + input_model, output_model = create_func_models(returns_mixed_nullable) + + result = await ToolExecutor.run( + func=returns_mixed_nullable, + definition=definition, + input_model=input_model, + output_model=output_model, + context=context, + ) + + assert result.error is None + # Explicit None must be kept (exclude_unset preserves it) + assert result.value == {"name": "hello", "note": None} + # Absent total=False field must NOT appear + assert "tag" not in result.value + + @pytest.mark.asyncio + async def test_optional_nested_typeddict_omits_absent_fields(self, catalog, context): + """A TypedDict field typed as Optional[NestedTypedDict] where the nested + TypedDict has total=False. + + When the nested TypedDict omits optional fields, those fields must NOT + appear as None in the serialized output. This validates that + create_model_from_typeddict unwraps Optional before checking is_typeddict, + so the nested TypedDict gets the _TypedDictBaseModel (exclude_unset) treatment. + """ + + class _InnerPartial(TypedDict, total=False): + label: str + count: int + + class _OuterWithOptionalNested(TypedDict): + id: int + nested: Optional[_InnerPartial] + + @tool + def returns_optional_nested_partial() -> Annotated[ + _OuterWithOptionalNested, "Outer with optional nested partial TypedDict" + ]: + """Tool returning an Optional nested TypedDict with absent fields.""" + return {"id": 1, "nested": {"label": "hello"}} # count intentionally absent + + definition = catalog.create_tool_definition( + returns_optional_nested_partial, toolkit_name="test", toolkit_version="1.0.0" + ) + input_model, output_model = create_func_models(returns_optional_nested_partial) + + result = await ToolExecutor.run( + func=returns_optional_nested_partial, + definition=definition, + input_model=input_model, + output_model=output_model, + context=context, + ) + + assert result.error is None + assert result.value == {"id": 1, "nested": {"label": "hello"}} + # The absent total=False field in the nested TypedDict must NOT appear + assert "count" not in result.value["nested"] diff --git a/libs/tests/tool/test_create_tool_definition_typeddict.py b/libs/tests/tool/test_create_tool_definition_typeddict.py index 1ac8ffc7..e58277bf 100644 --- a/libs/tests/tool/test_create_tool_definition_typeddict.py +++ b/libs/tests/tool/test_create_tool_definition_typeddict.py @@ -161,6 +161,7 @@ def func_returns_nested_typedicts() -> Annotated[CustomerDict, "Customer informa "price": ValueSchema(val_type="integer", enum=None), "stock_quantity": ValueSchema(val_type="integer", enum=None), }, + required_keys=["price", "product_name", "stock_quantity"], ), available_modes=["value", "error"], description="The product, price, and quantity", @@ -181,6 +182,7 @@ def func_returns_nested_typedicts() -> Annotated[CustomerDict, "Customer informa "price": ValueSchema(val_type="integer", enum=None), "stock_quantity": ValueSchema(val_type="integer", enum=None), }, + inner_required_keys=["price", "product_name", "stock_quantity"], ), available_modes=["value", "error"], description="The product, price, and quantity", @@ -206,6 +208,7 @@ def func_returns_nested_typedicts() -> Annotated[CustomerDict, "Customer informa "price": ValueSchema(val_type="integer", enum=None), "stock_quantity": ValueSchema(val_type="integer", enum=None), }, + required_keys=["price", "product_name", "stock_quantity"], ), ) ] @@ -226,6 +229,7 @@ def func_returns_nested_typedicts() -> Annotated[CustomerDict, "Customer informa "stock_quantity": ValueSchema(val_type="integer", enum=None), "description": ValueSchema(val_type="string", enum=None, nullable=True), }, + required_keys=["description", "price", "product_name", "stock_quantity"], ), available_modes=["value", "error"], description="The product, price, and quantity", @@ -252,6 +256,7 @@ def func_returns_nested_typedicts() -> Annotated[CustomerDict, "Customer informa val_type="array", inner_val_type="string", enum=None ), }, + required_keys=["category", "products"], ), ) ] @@ -305,8 +310,10 @@ def func_returns_nested_typedicts() -> Annotated[CustomerDict, "Customer informa "city": ValueSchema(val_type="string", enum=None), "zip_code": ValueSchema(val_type="string", enum=None), }, + required_keys=["city", "street", "zip_code"], ), }, + required_keys=["address", "email", "name"], ), available_modes=["value", "error"], description="Customer information with address",