diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index fe2983eefdc..499e1dee974 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -706,16 +706,11 @@ async def handler(args: dict[str, Any]) -> dict[str, Any]: except Exception as e: return {"content": [{"type": "text", "text": f"Error: {e}"}]} - # Get JSON schema from pydantic model - schema: dict[str, Any] = func_tool.input_model.model_json_schema() if func_tool.input_model else {} - input_schema: dict[str, Any] = { - "type": "object", - "properties": schema.get("properties", {}), - "required": schema.get("required", []), - } - # Preserve $defs for nested type references (Pydantic uses $defs for nested models) - if "$defs" in schema: - input_schema["$defs"] = schema["$defs"] + # Pass the full JSON schema through (from the pydantic model or the schema the tool was declared with), so + # keywords such as additionalProperties and $defs reach Claude. The SDK only uses a dict as a JSON schema when + # it has a string "type" and "properties", so both are always set. + schema: dict[str, Any] = func_tool.parameters() + input_schema: dict[str, Any] = {**schema, "type": "object", "properties": schema.get("properties") or {}} return SdkMcpTool( name=func_tool.name, diff --git a/python/packages/claude/tests/test_claude_agent.py b/python/packages/claude/tests/test_claude_agent.py index d8af59a0b92..c52940fbc1b 100644 --- a/python/packages/claude/tests/test_claude_agent.py +++ b/python/packages/claude/tests/test_claude_agent.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import copy import json from types import SimpleNamespace from typing import Any, cast @@ -793,6 +794,88 @@ def create_person(person: Person) -> str: # Verify the nested reference exists in properties assert "person" in input_schema["properties"] + async def test_function_tool_to_sdk_mcp_tool_uses_json_schema(self) -> None: + """Test that a tool declared with a JSON schema keeps the full schema, including additionalProperties.""" + schema: dict[str, Any] = { + "type": "object", + "title": "WeatherInput", + "properties": { + "city": {"type": "string", "description": "City name"}, + "unit": {"$ref": "#/$defs/Unit"}, + }, + "required": ["city"], + "additionalProperties": False, + "$defs": {"Unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}}, + } + + @tool(schema=schema) + def get_weather(city: str, unit: str = "celsius") -> str: + """Get the weather.""" + return f"Sunny in {city}" + + agent = ClaudeAgent() + sdk_tool = agent._function_tool_to_sdk_mcp_tool(get_weather) # type: ignore[reportPrivateUsage] + + assert sdk_tool.input_schema == schema + assert cast(dict[str, Any], sdk_tool.input_schema)["additionalProperties"] is False + result = await sdk_tool.handler({"city": "Paris"}) + assert result["content"][0]["text"] == "Sunny in Paris" + + def test_function_tool_to_sdk_mcp_tool_keeps_input_model_keywords(self) -> None: + """Test that top-level keywords of a pydantic input model, such as additionalProperties, are kept.""" + from pydantic import BaseModel, ConfigDict + + class WeatherInput(BaseModel): + model_config = ConfigDict(extra="forbid") + city: str + + @tool(schema=WeatherInput) + def get_weather(city: str) -> str: + """Get the weather.""" + return f"Sunny in {city}" + + agent = ClaudeAgent() + sdk_tool = agent._function_tool_to_sdk_mcp_tool(get_weather) # type: ignore[reportPrivateUsage] + + assert sdk_tool.input_schema == WeatherInput.model_json_schema() + assert cast(dict[str, Any], sdk_tool.input_schema)["additionalProperties"] is False + + @pytest.mark.parametrize( + ("schema", "expected"), + [ + ( + {"properties": {"city": {"type": "string"}}}, + {"type": "object", "properties": {"city": {"type": "string"}}}, + ), + ( + {"type": "object", "additionalProperties": False}, + {"type": "object", "properties": {}, "additionalProperties": False}, + ), + ( + {"type": ["object", "null"], "properties": {"city": {"type": "string"}}}, + {"type": "object", "properties": {"city": {"type": "string"}}}, + ), + ({"type": "object", "properties": None}, {"type": "object", "properties": {}}), + ({}, {"type": "object", "properties": {}}), + ], + ids=["no-type", "no-properties", "list-type", "null-properties", "empty"], + ) + def test_function_tool_to_sdk_mcp_tool_sets_type_and_properties( + self, schema: dict[str, Any], expected: dict[str, Any] + ) -> None: + """Test that a schema without an object type or properties still reaches the SDK as a JSON schema.""" + declared = copy.deepcopy(schema) + + @tool(name="lookup", description="Look something up.", schema=schema) + def lookup(**kwargs: Any) -> str: + return "ok" + + agent = ClaudeAgent() + sdk_tool = agent._function_tool_to_sdk_mcp_tool(lookup) # type: ignore[reportPrivateUsage] + + assert sdk_tool.input_schema == expected + assert lookup.parameters() == declared + async def test_tool_handler_success(self) -> None: """Test tool handler executes successfully."""