|
1 | 1 | # Copyright (c) Microsoft. All rights reserved. |
2 | 2 |
|
| 3 | +import copy |
3 | 4 | import json |
4 | 5 | from types import SimpleNamespace |
5 | 6 | from typing import Any, cast |
@@ -794,30 +795,87 @@ def create_person(person: Person) -> str: |
794 | 795 | assert "person" in input_schema["properties"] |
795 | 796 |
|
796 | 797 | async def test_function_tool_to_sdk_mcp_tool_uses_json_schema(self) -> None: |
797 | | - """Test that a tool declared with a JSON schema keeps its parameters.""" |
| 798 | + """Test that a tool declared with a JSON schema keeps the full schema, including additionalProperties.""" |
| 799 | + schema: dict[str, Any] = { |
| 800 | + "type": "object", |
| 801 | + "title": "WeatherInput", |
| 802 | + "properties": { |
| 803 | + "city": {"type": "string", "description": "City name"}, |
| 804 | + "unit": {"$ref": "#/$defs/Unit"}, |
| 805 | + }, |
| 806 | + "required": ["city"], |
| 807 | + "additionalProperties": False, |
| 808 | + "$defs": {"Unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}}, |
| 809 | + } |
798 | 810 |
|
799 | | - @tool( |
800 | | - schema={ |
801 | | - "type": "object", |
802 | | - "properties": {"city": {"type": "string", "description": "City name"}}, |
803 | | - "required": ["city"], |
804 | | - } |
805 | | - ) |
806 | | - def get_weather(city: str) -> str: |
| 811 | + @tool(schema=schema) |
| 812 | + def get_weather(city: str, unit: str = "celsius") -> str: |
807 | 813 | """Get the weather.""" |
808 | 814 | return f"Sunny in {city}" |
809 | 815 |
|
810 | 816 | agent = ClaudeAgent() |
811 | 817 | sdk_tool = agent._function_tool_to_sdk_mcp_tool(get_weather) # type: ignore[reportPrivateUsage] |
812 | 818 |
|
813 | | - assert sdk_tool.input_schema == { |
814 | | - "type": "object", |
815 | | - "properties": {"city": {"type": "string", "description": "City name"}}, |
816 | | - "required": ["city"], |
817 | | - } |
| 819 | + assert sdk_tool.input_schema == schema |
| 820 | + assert cast(dict[str, Any], sdk_tool.input_schema)["additionalProperties"] is False |
818 | 821 | result = await sdk_tool.handler({"city": "Paris"}) |
819 | 822 | assert result["content"][0]["text"] == "Sunny in Paris" |
820 | 823 |
|
| 824 | + def test_function_tool_to_sdk_mcp_tool_keeps_input_model_keywords(self) -> None: |
| 825 | + """Test that top-level keywords of a pydantic input model, such as additionalProperties, are kept.""" |
| 826 | + from pydantic import BaseModel, ConfigDict |
| 827 | + |
| 828 | + class WeatherInput(BaseModel): |
| 829 | + model_config = ConfigDict(extra="forbid") |
| 830 | + city: str |
| 831 | + |
| 832 | + @tool(schema=WeatherInput) |
| 833 | + def get_weather(city: str) -> str: |
| 834 | + """Get the weather.""" |
| 835 | + return f"Sunny in {city}" |
| 836 | + |
| 837 | + agent = ClaudeAgent() |
| 838 | + sdk_tool = agent._function_tool_to_sdk_mcp_tool(get_weather) # type: ignore[reportPrivateUsage] |
| 839 | + |
| 840 | + assert sdk_tool.input_schema == WeatherInput.model_json_schema() |
| 841 | + assert cast(dict[str, Any], sdk_tool.input_schema)["additionalProperties"] is False |
| 842 | + |
| 843 | + @pytest.mark.parametrize( |
| 844 | + ("schema", "expected"), |
| 845 | + [ |
| 846 | + ( |
| 847 | + {"properties": {"city": {"type": "string"}}}, |
| 848 | + {"type": "object", "properties": {"city": {"type": "string"}}}, |
| 849 | + ), |
| 850 | + ( |
| 851 | + {"type": "object", "additionalProperties": False}, |
| 852 | + {"type": "object", "properties": {}, "additionalProperties": False}, |
| 853 | + ), |
| 854 | + ( |
| 855 | + {"type": ["object", "null"], "properties": {"city": {"type": "string"}}}, |
| 856 | + {"type": "object", "properties": {"city": {"type": "string"}}}, |
| 857 | + ), |
| 858 | + ({"type": "object", "properties": None}, {"type": "object", "properties": {}}), |
| 859 | + ({}, {"type": "object", "properties": {}}), |
| 860 | + ], |
| 861 | + ids=["no-type", "no-properties", "list-type", "null-properties", "empty"], |
| 862 | + ) |
| 863 | + def test_function_tool_to_sdk_mcp_tool_sets_type_and_properties( |
| 864 | + self, schema: dict[str, Any], expected: dict[str, Any] |
| 865 | + ) -> None: |
| 866 | + """Test that a schema without an object type or properties still reaches the SDK as a JSON schema.""" |
| 867 | + declared = copy.deepcopy(schema) |
| 868 | + |
| 869 | + @tool(name="lookup", description="Look something up.", schema=schema) |
| 870 | + def lookup(**kwargs: Any) -> str: |
| 871 | + return "ok" |
| 872 | + |
| 873 | + agent = ClaudeAgent() |
| 874 | + sdk_tool = agent._function_tool_to_sdk_mcp_tool(lookup) # type: ignore[reportPrivateUsage] |
| 875 | + |
| 876 | + assert sdk_tool.input_schema == expected |
| 877 | + assert lookup.parameters() == declared |
| 878 | + |
821 | 879 | async def test_tool_handler_success(self) -> None: |
822 | 880 | """Test tool handler executes successfully.""" |
823 | 881 |
|
|
0 commit comments