Skip to content

Commit dfd9f80

Browse files
committed
Pass the full tool JSON schema to the Claude Agent SDK
1 parent 8ccdaf3 commit dfd9f80

2 files changed

Lines changed: 76 additions & 23 deletions

File tree

‎python/packages/claude/agent_framework_claude/_agent.py‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -706,16 +706,11 @@ async def handler(args: dict[str, Any]) -> dict[str, Any]:
706706
except Exception as e:
707707
return {"content": [{"type": "text", "text": f"Error: {e}"}]}
708708

709-
# Get JSON schema from the pydantic model or the JSON schema the tool was declared with
709+
# Pass the full JSON schema through (from the pydantic model or the schema the tool was declared with), so
710+
# keywords such as additionalProperties and $defs reach Claude. The SDK only uses a dict as a JSON schema when
711+
# it has a string "type" and "properties", so both are always set.
710712
schema: dict[str, Any] = func_tool.parameters()
711-
input_schema: dict[str, Any] = {
712-
"type": "object",
713-
"properties": schema.get("properties", {}),
714-
"required": schema.get("required", []),
715-
}
716-
# Preserve $defs for nested type references (Pydantic uses $defs for nested models)
717-
if "$defs" in schema:
718-
input_schema["$defs"] = schema["$defs"]
713+
input_schema: dict[str, Any] = {**schema, "type": "object", "properties": schema.get("properties") or {}}
719714

720715
return SdkMcpTool(
721716
name=func_tool.name,

‎python/packages/claude/tests/test_claude_agent.py‎

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

3+
import copy
34
import json
45
from types import SimpleNamespace
56
from typing import Any, cast
@@ -794,30 +795,87 @@ def create_person(person: Person) -> str:
794795
assert "person" in input_schema["properties"]
795796

796797
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+
}
798810

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:
807813
"""Get the weather."""
808814
return f"Sunny in {city}"
809815

810816
agent = ClaudeAgent()
811817
sdk_tool = agent._function_tool_to_sdk_mcp_tool(get_weather) # type: ignore[reportPrivateUsage]
812818

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
818821
result = await sdk_tool.handler({"city": "Paris"})
819822
assert result["content"][0]["text"] == "Sunny in Paris"
820823

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+
821879
async def test_tool_handler_success(self) -> None:
822880
"""Test tool handler executes successfully."""
823881

0 commit comments

Comments
 (0)