fix: TypedDict total=False output breaks validation (#816)
When a tool’s output TypedDict uses total=False, MCP clients reject the
response with:
```
MCP error -32602: Structured content does not match the tool's output schema
```
Note that the bug also exists for the Engine transport
(/worker/tools/execute), but since the engine doesn't validate the
output schema, the bug never surfaced. This PR addresses the problem
holistically (MCP and Engine) in preparation for a future where the
Engine transport validates output schemas.
Two bugs combined to cause this:
1. Schema: The outputSchema had no required array and declared all
fields as strict types (e.g. "type": "string"), making every field look
mandatory and non-null.
2. Serialization: model_dump() on TypedDict-derived Pydantic models
emitted None for absent optional fields. A tool returning {"name":
"hello"} produced {"name": "hello", "optional_field": null} which is a
value the schema forbids.
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Medium Risk**
> Adjusts core schema generation and MCP JSON Schema conversion for
TypedDicts, affecting how tool input/output contracts are emitted and
validated across clients; mistakes could break compatibility or
validation behavior.
>
> **Overview**
> Fixes MCP/engine validation failures for `TypedDict(total=False)`
outputs by ensuring absent optional keys are **omitted from serialized
output** and that emitted schemas correctly describe **required vs
optional** keys.
>
> `arcade-core` now tracks `required_keys`/`inner_required_keys` and
per-field `nullable` in `ValueSchema`, derives required sets from
TypedDict `__required_keys__`, and unwraps `Optional[T]` to support
optional nested TypedDicts; TypedDict-derived Pydantic models now
`model_dump(exclude_unset=True)` to avoid leaking missing fields as
`null`.
>
> `arcade-mcp-server` JSON Schema conversion now emits `required` arrays
(including for arrays of objects), supports `nullable` by generating
`type: [<type>, "null"]` (and `enum` including `None`), and treats
nullable top-level objects as valid unwrapped output schemas. Adds
focused unit/end-to-end tests plus an expanded example server
demonstrating total-false, mixed required/optional, nullable, and
optional-nested TypedDict outputs, and bumps package
versions/dependencies accordingly.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
53fe8365f613053599130520b75f30b614b465ca. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
This commit is contained in:
parent
5075b6d40e
commit
3204201360
10 changed files with 654 additions and 24 deletions
|
|
@ -4,7 +4,7 @@ version = "0.1.0"
|
||||||
description = "MCP Server created with Arcade.dev"
|
description = "MCP Server created with Arcade.dev"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arcade-mcp-server>=1.19.0,<2.0.0",
|
"arcade-mcp-server>=1.19.1,<2.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|
|
||||||
|
|
@ -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.
|
display tool results without guessing the shape of the data.
|
||||||
|
|
||||||
Tools in this server progress from simple to complex:
|
Tools in this server progress from simple to complex:
|
||||||
- calculate_statistics — flat TypedDict (all scalar fields)
|
- calculate_statistics — flat TypedDict (all scalar fields)
|
||||||
- analyze_text — TypedDict with a list field
|
- analyze_text — TypedDict with a list field
|
||||||
- get_calendar_info — TypedDict with a nested TypedDict field
|
- get_calendar_info — TypedDict with a nested TypedDict field
|
||||||
- parse_url — TypedDict with two levels of nesting
|
- 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
|
import sys
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from statistics import mean, median
|
from statistics import mean, median
|
||||||
from typing import Annotated
|
from typing import Annotated, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from arcade_mcp_server import MCPApp
|
from arcade_mcp_server import MCPApp
|
||||||
|
|
@ -88,6 +92,69 @@ class ParsedUrl(TypedDict):
|
||||||
domain: str
|
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
|
# 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
|
# Entry point
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from typing import (
|
||||||
Annotated,
|
Annotated,
|
||||||
Any,
|
Any,
|
||||||
Literal,
|
Literal,
|
||||||
|
Optional,
|
||||||
Union,
|
Union,
|
||||||
cast,
|
cast,
|
||||||
get_args,
|
get_args,
|
||||||
|
|
@ -102,6 +103,9 @@ class WireTypeInfo:
|
||||||
properties: dict[str, "WireTypeInfo"] | None = None
|
properties: dict[str, "WireTypeInfo"] | None = None
|
||||||
inner_properties: dict[str, "WireTypeInfo"] | None = None
|
inner_properties: dict[str, "WireTypeInfo"] | None = None
|
||||||
description: str | 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):
|
class ToolMeta(BaseModel):
|
||||||
|
|
@ -762,6 +766,7 @@ def get_wire_type_info(_type: type) -> WireTypeInfo:
|
||||||
# If so, get the inner (enclosed) type
|
# If so, get the inner (enclosed) type
|
||||||
is_list = get_origin(_type) is list
|
is_list = get_origin(_type) is list
|
||||||
inner_properties = None
|
inner_properties = None
|
||||||
|
inner_required_keys = None
|
||||||
|
|
||||||
if is_list:
|
if is_list:
|
||||||
inner_type = get_args(_type)[0]
|
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 type has properties (it's a complex object), propagate them
|
||||||
if inner_info.properties:
|
if inner_info.properties:
|
||||||
inner_properties = 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
|
# If inner type is array (nested arrays), propagate inner_properties
|
||||||
elif inner_info.inner_properties:
|
elif inner_info.inner_properties:
|
||||||
inner_properties = inner_info.inner_properties
|
inner_properties = inner_info.inner_properties
|
||||||
|
inner_required_keys = inner_info.inner_required_keys
|
||||||
else:
|
else:
|
||||||
inner_wire_type = None
|
inner_wire_type = None
|
||||||
|
|
||||||
|
|
@ -803,8 +810,9 @@ def get_wire_type_info(_type: type) -> WireTypeInfo:
|
||||||
|
|
||||||
# Extract properties for complex types
|
# Extract properties for complex types
|
||||||
properties = None
|
properties = None
|
||||||
|
required_keys = None
|
||||||
if wire_type == "json" and not is_list:
|
if wire_type == "json" and not is_list:
|
||||||
properties = extract_properties(type_to_check)
|
properties, required_keys = extract_properties(type_to_check)
|
||||||
|
|
||||||
return WireTypeInfo(
|
return WireTypeInfo(
|
||||||
wire_type,
|
wire_type,
|
||||||
|
|
@ -812,6 +820,8 @@ def get_wire_type_info(_type: type) -> WireTypeInfo:
|
||||||
enum_values if is_enum else None,
|
enum_values if is_enum else None,
|
||||||
properties,
|
properties,
|
||||||
inner_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
|
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.
|
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 = {}
|
properties = {}
|
||||||
|
|
||||||
|
|
@ -868,6 +883,8 @@ def extract_properties(type_to_check: type) -> dict[str, WireTypeInfo] | None:
|
||||||
wire_info = get_wire_type_info(field_type)
|
wire_info = get_wire_type_info(field_type)
|
||||||
properties[field_name] = wire_info
|
properties[field_name] = wire_info
|
||||||
|
|
||||||
|
return (properties or None, None)
|
||||||
|
|
||||||
# Handle TypedDict
|
# Handle TypedDict
|
||||||
elif is_typeddict(type_to_check):
|
elif is_typeddict(type_to_check):
|
||||||
# Get type hints for the TypedDict
|
# 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():
|
for field_name, field_type in type_hints.items():
|
||||||
# Handle Optional types (Union[T, None])
|
# 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
|
# Extract the non-None type from Optional
|
||||||
field_type = next(arg for arg in get_args(field_type) if arg is not type(None))
|
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)
|
wire_info = get_wire_type_info(field_type)
|
||||||
|
if is_nullable:
|
||||||
|
wire_info.nullable = True
|
||||||
|
|
||||||
# Add description if available
|
# Add description if available
|
||||||
if field_name in field_descriptions:
|
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
|
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])
|
# Handle regular dict with type annotations (e.g., dict[str, Any])
|
||||||
elif get_origin(type_to_check) is dict:
|
elif get_origin(type_to_check) is dict:
|
||||||
# For generic dicts, we can't extract specific properties
|
# 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:
|
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,
|
properties=properties,
|
||||||
inner_properties=inner_properties,
|
inner_properties=inner_properties,
|
||||||
description=wire_info.description,
|
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]:
|
def create_model_from_typeddict(typeddict_class: type, model_name: str) -> type[BaseModel]:
|
||||||
"""
|
"""
|
||||||
Create a Pydantic model from a TypedDict class.
|
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
|
# Check if field is required
|
||||||
is_required = field_name in getattr(typeddict_class, "__required_keys__", set())
|
is_required = field_name in getattr(typeddict_class, "__required_keys__", set())
|
||||||
|
|
||||||
# Handle nested TypedDict
|
# Unwrap Optional[T] (i.e. T | None) so we can detect nested TypedDicts
|
||||||
if is_typeddict(field_type):
|
is_optional_type = is_strict_optional(field_type)
|
||||||
nested_model = create_model_from_typeddict(field_type, f"{model_name}_{field_name}")
|
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:
|
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:
|
else:
|
||||||
field_definitions[field_name] = (nested_model, Field(default=None))
|
field_definitions[field_name] = (Optional[nested_model], Field(default=None))
|
||||||
else:
|
else:
|
||||||
if is_required:
|
if is_required:
|
||||||
field_definitions[field_name] = (field_type, Field())
|
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))
|
field_definitions[field_name] = (field_type, Field(default=None))
|
||||||
|
|
||||||
# Create and return the Pydantic model
|
# 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(
|
def to_tool_secret_requirements(
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,15 @@ class ValueSchema(BaseModel):
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
"""Optional description of the value."""
|
"""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):
|
class InputParameter(BaseModel):
|
||||||
"""A parameter that can be passed to a tool."""
|
"""A parameter that can be passed to a tool."""
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "arcade-core"
|
name = "arcade-core"
|
||||||
version = "4.6.0"
|
version = "4.6.1"
|
||||||
description = "Arcade Core - Core library for Arcade platform"
|
description = "Arcade Core - Core library for Arcade platform"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,9 @@ def _build_value_schema_json(value_schema: Any) -> dict[str, Any]:
|
||||||
inner_schema = _value_schema_to_json_schema(value_schema)
|
inner_schema = _value_schema_to_json_schema(value_schema)
|
||||||
|
|
||||||
# Object return types are already top-level objects, emit directly.
|
# 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
|
return inner_schema
|
||||||
|
|
||||||
# Primitives/arrays must be wrapped so outputSchema.type is "object" per MCP spec.
|
# 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]:
|
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.
|
"""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)
|
schema["properties"][prop_name] = _value_schema_to_json_schema(prop_schema)
|
||||||
if getattr(prop_schema, "description", None):
|
if getattr(prop_schema, "description", None):
|
||||||
schema["properties"][prop_name]["description"] = prop_schema.description
|
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)}
|
schema = {"type": _map_type_to_json_schema_type(val_type)}
|
||||||
if getattr(value_schema, "enum", None):
|
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)
|
items_schema["properties"][prop_name] = _value_schema_to_json_schema(prop_schema)
|
||||||
if getattr(prop_schema, "description", None):
|
if getattr(prop_schema, "description", None):
|
||||||
items_schema["properties"][prop_name]["description"] = prop_schema.description
|
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
|
schema["items"] = items_schema
|
||||||
return schema
|
return _apply_nullable(schema, value_schema)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "arcade-mcp-server"
|
name = "arcade-mcp-server"
|
||||||
version = "1.19.0"
|
version = "1.19.1"
|
||||||
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
|
description = "Model Context Protocol (MCP) server framework for Arcade.dev"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{ name = "Arcade.dev" }]
|
authors = [{ name = "Arcade.dev" }]
|
||||||
|
|
@ -21,7 +21,7 @@ classifiers = [
|
||||||
]
|
]
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
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-serve>=3.2.0,<4.0.0",
|
||||||
"arcade-tdk>=3.6.0,<4.0.0",
|
"arcade-tdk>=3.6.0,<4.0.0",
|
||||||
"arcadepy>=1.5.0",
|
"arcadepy>=1.5.0",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import json
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
import pytest
|
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 (
|
from arcade_core.schema import (
|
||||||
InputParameter,
|
InputParameter,
|
||||||
ToolDefinition,
|
ToolDefinition,
|
||||||
|
|
@ -660,3 +660,252 @@ class TestConvertContentToStructuredContent:
|
||||||
|
|
||||||
result = convert_content_to_structured_content(Custom())
|
result = convert_content_to_structured_content(Custom())
|
||||||
assert result == {"result": "custom-str"}
|
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"]
|
||||||
|
|
|
||||||
|
|
@ -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:
|
class TestTypeDictOutputExecution:
|
||||||
"""Test TypedDict outputs through the full execution pipeline."""
|
"""Test TypedDict outputs through the full execution pipeline."""
|
||||||
|
|
||||||
|
|
@ -248,3 +274,137 @@ class TestTypeDictOutputExecution:
|
||||||
{"name": "string", "data": "test"},
|
{"name": "string", "data": "test"},
|
||||||
{"name": "typed", "value": 99}, # TypedDict becomes regular dict at runtime
|
{"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"]
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,7 @@ def func_returns_nested_typedicts() -> Annotated[CustomerDict, "Customer informa
|
||||||
"price": ValueSchema(val_type="integer", enum=None),
|
"price": ValueSchema(val_type="integer", enum=None),
|
||||||
"stock_quantity": 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"],
|
available_modes=["value", "error"],
|
||||||
description="The product, price, and quantity",
|
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),
|
"price": ValueSchema(val_type="integer", enum=None),
|
||||||
"stock_quantity": 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"],
|
available_modes=["value", "error"],
|
||||||
description="The product, price, and quantity",
|
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),
|
"price": ValueSchema(val_type="integer", enum=None),
|
||||||
"stock_quantity": 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),
|
"stock_quantity": ValueSchema(val_type="integer", enum=None),
|
||||||
"description": ValueSchema(val_type="string", enum=None, nullable=True),
|
"description": ValueSchema(val_type="string", enum=None, nullable=True),
|
||||||
},
|
},
|
||||||
|
required_keys=["description", "price", "product_name", "stock_quantity"],
|
||||||
),
|
),
|
||||||
available_modes=["value", "error"],
|
available_modes=["value", "error"],
|
||||||
description="The product, price, and quantity",
|
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
|
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),
|
"city": ValueSchema(val_type="string", enum=None),
|
||||||
"zip_code": 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"],
|
available_modes=["value", "error"],
|
||||||
description="Customer information with address",
|
description="Customer information with address",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue