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"
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue