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:
Eric Gustin 2026-04-09 17:47:57 -07:00 committed by GitHub
parent 5075b6d40e
commit 3204201360
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 654 additions and 24 deletions

View file

@ -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]

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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(

View file

@ -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."""

View file

@ -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" }

View file

@ -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)

View file

@ -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",

View file

@ -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"]

View file

@ -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"]

View file

@ -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",