@@ -89,6 +89,149 @@ def test_input_schema_without_properties_key(self):
8989 assert lc_tool .args_schema is not None
9090
9191
92+ class TestMcpToolToLangchainTypeMapping :
93+ """Tests that JSON Schema types are mapped to the correct Python types."""
94+
95+ def _tool_with_types (self , properties : dict , required : list [str ] | None = None ) -> MCPTool :
96+ return MCPTool (
97+ name = "typed_tool" ,
98+ server_name = "server" ,
99+ description = "desc" ,
100+ input_schema = {
101+ "type" : "object" ,
102+ "required" : required or [],
103+ "properties" : properties ,
104+ },
105+ url = "https://example.com/mcp" ,
106+ )
107+
108+ def test_string_type_maps_to_str (self ):
109+ lc_tool = mcp_tool_to_langchain (
110+ self ._tool_with_types ({"name" : {"type" : "string" }}, required = ["name" ]),
111+ AsyncMock (),
112+ lambda : "token" ,
113+ )
114+ assert _schema_fields (lc_tool )["name" ].annotation is str
115+
116+ def test_integer_type_maps_to_int (self ):
117+ lc_tool = mcp_tool_to_langchain (
118+ self ._tool_with_types ({"limit" : {"type" : "integer" }}, required = ["limit" ]),
119+ AsyncMock (),
120+ lambda : "token" ,
121+ )
122+ assert _schema_fields (lc_tool )["limit" ].annotation is int
123+
124+ def test_number_type_maps_to_float (self ):
125+ lc_tool = mcp_tool_to_langchain (
126+ self ._tool_with_types ({"ratio" : {"type" : "number" }}, required = ["ratio" ]),
127+ AsyncMock (),
128+ lambda : "token" ,
129+ )
130+ assert _schema_fields (lc_tool )["ratio" ].annotation is float
131+
132+ def test_boolean_type_maps_to_bool (self ):
133+ lc_tool = mcp_tool_to_langchain (
134+ self ._tool_with_types ({"active" : {"type" : "boolean" }}, required = ["active" ]),
135+ AsyncMock (),
136+ lambda : "token" ,
137+ )
138+ assert _schema_fields (lc_tool )["active" ].annotation is bool
139+
140+ def test_array_type_maps_to_list (self ):
141+ lc_tool = mcp_tool_to_langchain (
142+ self ._tool_with_types ({"tags" : {"type" : "array" }}, required = ["tags" ]),
143+ AsyncMock (),
144+ lambda : "token" ,
145+ )
146+ assert _schema_fields (lc_tool )["tags" ].annotation is list
147+
148+ def test_object_type_maps_to_dict (self ):
149+ lc_tool = mcp_tool_to_langchain (
150+ self ._tool_with_types ({"meta" : {"type" : "object" }}, required = ["meta" ]),
151+ AsyncMock (),
152+ lambda : "token" ,
153+ )
154+ assert _schema_fields (lc_tool )["meta" ].annotation is dict
155+
156+ def test_unknown_type_maps_to_any (self ):
157+ from typing import Any
158+ lc_tool = mcp_tool_to_langchain (
159+ self ._tool_with_types ({"data" : {"type" : "unknown" }}, required = ["data" ]),
160+ AsyncMock (),
161+ lambda : "token" ,
162+ )
163+ assert _schema_fields (lc_tool )["data" ].annotation is Any
164+
165+ def test_missing_type_maps_to_any (self ):
166+ from typing import Any
167+ lc_tool = mcp_tool_to_langchain (
168+ self ._tool_with_types ({"data" : {}}, required = ["data" ]),
169+ AsyncMock (),
170+ lambda : "token" ,
171+ )
172+ assert _schema_fields (lc_tool )["data" ].annotation is Any
173+
174+ def test_optional_non_string_field_is_nullable (self ):
175+ lc_tool = mcp_tool_to_langchain (
176+ self ._tool_with_types ({"limit" : {"type" : "integer" }}),
177+ AsyncMock (),
178+ lambda : "token" ,
179+ )
180+ field = _schema_fields (lc_tool )["limit" ]
181+ assert not field .is_required ()
182+ # annotation should be int | None
183+ import types as _types
184+ assert isinstance (field .annotation , _types .UnionType )
185+ assert int in field .annotation .__args__
186+ assert type (None ) in field .annotation .__args__
187+
188+ def test_array_type_integer_null_maps_to_int (self ):
189+ lc_tool = mcp_tool_to_langchain (
190+ self ._tool_with_types ({"limit" : {"type" : ["integer" , "null" ]}}, required = ["limit" ]),
191+ AsyncMock (),
192+ lambda : "token" ,
193+ )
194+ field = _schema_fields (lc_tool )["limit" ]
195+ import types as _types
196+ assert isinstance (field .annotation , _types .UnionType )
197+ assert int in field .annotation .__args__
198+ assert type (None ) in field .annotation .__args__
199+
200+ def test_array_type_number_null_maps_to_float (self ):
201+ lc_tool = mcp_tool_to_langchain (
202+ self ._tool_with_types ({"ratio" : {"type" : ["number" , "null" ]}}, required = ["ratio" ]),
203+ AsyncMock (),
204+ lambda : "token" ,
205+ )
206+ field = _schema_fields (lc_tool )["ratio" ]
207+ import types as _types
208+ assert isinstance (field .annotation , _types .UnionType )
209+ assert float in field .annotation .__args__
210+ assert type (None ) in field .annotation .__args__
211+
212+ def test_array_type_multiple_scalars_uses_first_non_null (self ):
213+ # e.g. {"type": ["number", "string", "null"]} — pick "number"
214+ lc_tool = mcp_tool_to_langchain (
215+ self ._tool_with_types ({"val" : {"type" : ["number" , "string" , "null" ]}}, required = ["val" ]),
216+ AsyncMock (),
217+ lambda : "token" ,
218+ )
219+ field = _schema_fields (lc_tool )["val" ]
220+ import types as _types
221+ assert isinstance (field .annotation , _types .UnionType )
222+ assert float in field .annotation .__args__
223+ assert type (None ) in field .annotation .__args__
224+
225+ def test_array_type_without_null_is_not_nullable (self ):
226+ lc_tool = mcp_tool_to_langchain (
227+ self ._tool_with_types ({"count" : {"type" : ["integer" ]}}, required = ["count" ]),
228+ AsyncMock (),
229+ lambda : "token" ,
230+ )
231+ field = _schema_fields (lc_tool )["count" ]
232+ assert field .annotation is int
233+
234+
92235class TestMcpToolToLangchainInvocation :
93236 """End-to-end invocation tests: verify what actually reaches call_tool."""
94237
0 commit comments