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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/strands/tools/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from ..experimental.tools import ToolProvider
from ..types.tools import AgentTool, ToolSpec
from .loader import load_tool_from_string, load_tools_from_module
from .tools import PythonAgentTool, normalize_schema, normalize_tool_spec
from .tools import COMPOSITION_KEYWORDS, PythonAgentTool, normalize_schema, normalize_tool_spec

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -602,7 +602,8 @@ def validate_tool_spec(self, tool_spec: ToolSpec) -> None:
if "$ref" in prop_def:
continue

if "type" not in prop_def:
has_composition = any(kw in prop_def for kw in COMPOSITION_KEYWORDS)
if "type" not in prop_def and not has_composition:
prop_def["type"] = "string"
if "description" not in prop_def:
prop_def["description"] = f"Property {prop_name}"
Expand Down
8 changes: 7 additions & 1 deletion src/strands/tools/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@

logger = logging.getLogger(__name__)

# JSON Schema composition keywords that define type constraints.
# Properties with these should not get a default type: "string" added.
COMPOSITION_KEYWORDS = ("anyOf", "oneOf", "allOf", "not")


class InvalidToolUseNameException(Exception):
"""Exception raised when a tool use has an invalid name."""
Expand Down Expand Up @@ -88,7 +92,9 @@ def _normalize_property(prop_name: str, prop_def: Any) -> dict[str, Any]:
if "$ref" in normalized_prop:
return normalized_prop

normalized_prop.setdefault("type", "string")
has_composition = any(kw in normalized_prop for kw in COMPOSITION_KEYWORDS)
if not has_composition:
normalized_prop.setdefault("type", "string")
normalized_prop.setdefault("description", f"Property {prop_name}")
return normalized_prop

Expand Down
122 changes: 122 additions & 0 deletions tests/strands/tools/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,3 +389,125 @@ async def track_load_tools(*args, **kwargs):

# Verify add_consumer was called with the registry ID
mock_provider.add_consumer.assert_called_once_with(registry._registry_id)


def test_validate_tool_spec_with_anyof_property():
"""Test that validate_tool_spec does not add type: 'string' to anyOf properties.

This is important for MCP tools that use anyOf for optional/union types like
Optional[List[str]]. Adding type: 'string' causes models to return string-encoded
JSON instead of proper arrays/objects.
"""
tool_spec = {
"name": "test_tool",
"description": "A test tool",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"regular_field": {}, # Should get type: "string"
"anyof_field": {
"anyOf": [
{"type": "array", "items": {"type": "string"}},
{"type": "null"},
]
},
},
}
},
}

registry = ToolRegistry()
registry.validate_tool_spec(tool_spec)

props = tool_spec["inputSchema"]["json"]["properties"]

# Regular field should get default type: "string"
assert props["regular_field"]["type"] == "string"
assert props["regular_field"]["description"] == "Property regular_field"

# anyOf field should NOT get type: "string" added
assert "type" not in props["anyof_field"], "anyOf property should not have type added"
assert "anyOf" in props["anyof_field"], "anyOf should be preserved"
assert props["anyof_field"]["description"] == "Property anyof_field"


def test_validate_tool_spec_with_composition_keywords():
"""Test that validate_tool_spec does not add type: 'string' to composition keyword properties.

JSON Schema composition keywords (anyOf, oneOf, allOf, not) define type constraints.
Properties using these should not get a default type added.
"""
tool_spec = {
"name": "test_tool",
"description": "A test tool",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"regular_field": {}, # Should get type: "string"
"oneof_field": {
"oneOf": [
{"type": "string"},
{"type": "integer"},
]
},
"allof_field": {
"allOf": [
{"minimum": 0},
{"maximum": 100},
]
},
"not_field": {"not": {"type": "null"}},
},
}
},
}

registry = ToolRegistry()
registry.validate_tool_spec(tool_spec)

props = tool_spec["inputSchema"]["json"]["properties"]

# Regular field should get default type: "string"
assert props["regular_field"]["type"] == "string"

# Composition keyword fields should NOT get type: "string" added
assert "type" not in props["oneof_field"], "oneOf property should not have type added"
assert "oneOf" in props["oneof_field"], "oneOf should be preserved"

assert "type" not in props["allof_field"], "allOf property should not have type added"
assert "allOf" in props["allof_field"], "allOf should be preserved"

assert "type" not in props["not_field"], "not property should not have type added"
assert "not" in props["not_field"], "not should be preserved"

# All should have descriptions
for field in ["oneof_field", "allof_field", "not_field"]:
assert props[field]["description"] == f"Property {field}"


def test_validate_tool_spec_with_ref_property():
"""Test that validate_tool_spec does not modify $ref properties."""
tool_spec = {
"name": "test_tool",
"description": "A test tool",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"ref_field": {"$ref": "#/$defs/SomeType"},
},
}
},
}

registry = ToolRegistry()
registry.validate_tool_spec(tool_spec)

props = tool_spec["inputSchema"]["json"]["properties"]

# $ref field should not be modified
assert props["ref_field"] == {"$ref": "#/$defs/SomeType"}
assert "type" not in props["ref_field"]
assert "description" not in props["ref_field"]
15 changes: 15 additions & 0 deletions tests/strands/tools/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,3 +509,18 @@ async def test_stream(identity_tool, alist):
tru_events = await alist(stream)
exp_events = [ToolResultEvent(({"tool_use": 1}, 2))]
assert tru_events == exp_events


def test_normalize_schema_with_anyof():
"""Test that anyOf properties don't get default type."""
schema = {
"type": "object",
"properties": {
"optional_field": {"anyOf": [{"items": {"type": "string"}, "type": "array"}, {"type": "null"}]},
"regular_field": {},
},
}
normalized = normalize_schema(schema)

assert "type" not in normalized["properties"]["optional_field"]
assert normalized["properties"]["regular_field"]["type"] == "string"
Loading