@@ -316,6 +316,161 @@ async def test_optional_underscore_param_restored_when_supplied(self):
316316 assert "Product" not in kwargs
317317
318318
319+ class TestMcpToolToLangchainFieldMetadata :
320+ """JSON Schema property metadata is forwarded to Pydantic Field.
321+
322+ Fields with a native Pydantic equivalent go there directly; everything
323+ else is preserved via json_schema_extra so the LLM still sees them.
324+ """
325+
326+ def _tool (self , properties : dict , required : list [str ] | None = None ) -> MCPTool :
327+ return MCPTool (
328+ name = "meta_tool" ,
329+ server_name = "server" ,
330+ description = "desc" ,
331+ input_schema = {
332+ "type" : "object" ,
333+ "required" : required or [],
334+ "properties" : properties ,
335+ },
336+ url = "https://example.com/mcp" ,
337+ )
338+
339+ # --- native Field kwargs ---
340+
341+ def test_description_preserved_on_required_field (self ):
342+ lc_tool = mcp_tool_to_langchain (
343+ self ._tool ({"s" : {"type" : "string" , "description" : "The status" }}, required = ["s" ]),
344+ AsyncMock (), lambda : "token" ,
345+ )
346+ assert _schema_fields (lc_tool )["s" ].description == "The status"
347+
348+ def test_description_preserved_on_optional_field (self ):
349+ lc_tool = mcp_tool_to_langchain (
350+ self ._tool ({"s" : {"type" : "string" , "description" : "The status" }}),
351+ AsyncMock (), lambda : "token" ,
352+ )
353+ assert _schema_fields (lc_tool )["s" ].description == "The status"
354+
355+ def test_title_preserved (self ):
356+ lc_tool = mcp_tool_to_langchain (
357+ self ._tool ({"n" : {"type" : "string" , "title" : "OriginalName" }}, required = ["n" ]),
358+ AsyncMock (), lambda : "token" ,
359+ )
360+ assert _schema_fields (lc_tool )["n" ].title == "OriginalName"
361+
362+ def test_examples_preserved_as_native_field_kwarg (self ):
363+ lc_tool = mcp_tool_to_langchain (
364+ self ._tool ({"n" : {"type" : "string" , "examples" : ["Alice" , "Bob" ]}}, required = ["n" ]),
365+ AsyncMock (), lambda : "token" ,
366+ )
367+ assert _schema_fields (lc_tool )["n" ].examples == ["Alice" , "Bob" ]
368+
369+ def test_deprecated_preserved (self ):
370+ lc_tool = mcp_tool_to_langchain (
371+ self ._tool ({"n" : {"type" : "string" , "deprecated" : True }}, required = ["n" ]),
372+ AsyncMock (), lambda : "token" ,
373+ )
374+ assert _schema_fields (lc_tool )["n" ].deprecated is True
375+
376+ def test_pattern_preserved (self ):
377+ lc_tool = mcp_tool_to_langchain (
378+ self ._tool ({"n" : {"type" : "string" , "pattern" : "^[a-z]+$" }}, required = ["n" ]),
379+ AsyncMock (), lambda : "token" ,
380+ )
381+ assert _schema_fields (lc_tool )["n" ].metadata # pattern lives in metadata
382+
383+ def test_min_max_length_preserved (self ):
384+ lc_tool = mcp_tool_to_langchain (
385+ self ._tool ({"n" : {"type" : "string" , "minLength" : 2 , "maxLength" : 50 }}, required = ["n" ]),
386+ AsyncMock (), lambda : "token" ,
387+ )
388+ schema = lc_tool .args_schema .model_json_schema ()
389+ assert schema ["properties" ]["n" ]["minLength" ] == 2
390+ assert schema ["properties" ]["n" ]["maxLength" ] == 50
391+
392+ def test_minimum_maximum_preserved (self ):
393+ lc_tool = mcp_tool_to_langchain (
394+ self ._tool ({"v" : {"type" : "integer" , "minimum" : 1 , "maximum" : 100 }}, required = ["v" ]),
395+ AsyncMock (), lambda : "token" ,
396+ )
397+ schema = lc_tool .args_schema .model_json_schema ()
398+ assert schema ["properties" ]["v" ]["minimum" ] == 1
399+ assert schema ["properties" ]["v" ]["maximum" ] == 100
400+
401+ # --- json_schema_extra bucket ---
402+
403+ def test_enum_preserved_in_json_schema_extra (self ):
404+ lc_tool = mcp_tool_to_langchain (
405+ self ._tool ({"c" : {"type" : "string" , "enum" : ["red" , "green" , "blue" ]}}, required = ["c" ]),
406+ AsyncMock (), lambda : "token" ,
407+ )
408+ schema = lc_tool .args_schema .model_json_schema ()
409+ assert schema ["properties" ]["c" ]["enum" ] == ["red" , "green" , "blue" ]
410+
411+ def test_default_preserved_in_json_schema_extra (self ):
412+ lc_tool = mcp_tool_to_langchain (
413+ self ._tool ({"c" : {"type" : "string" , "default" : "active" }}, required = ["c" ]),
414+ AsyncMock (), lambda : "token" ,
415+ )
416+ schema = lc_tool .args_schema .model_json_schema ()
417+ assert schema ["properties" ]["c" ]["default" ] == "active"
418+
419+ def test_example_preserved_in_json_schema_extra (self ):
420+ lc_tool = mcp_tool_to_langchain (
421+ self ._tool ({"c" : {"type" : "string" , "example" : "hello" }}, required = ["c" ]),
422+ AsyncMock (), lambda : "token" ,
423+ )
424+ schema = lc_tool .args_schema .model_json_schema ()
425+ assert schema ["properties" ]["c" ]["example" ] == "hello"
426+
427+ def test_format_preserved_in_json_schema_extra (self ):
428+ lc_tool = mcp_tool_to_langchain (
429+ self ._tool ({"ts" : {"type" : "string" , "format" : "date-time" }}, required = ["ts" ]),
430+ AsyncMock (), lambda : "token" ,
431+ )
432+ schema = lc_tool .args_schema .model_json_schema ()
433+ assert schema ["properties" ]["ts" ]["format" ] == "date-time"
434+
435+ def test_const_preserved_in_json_schema_extra (self ):
436+ lc_tool = mcp_tool_to_langchain (
437+ self ._tool ({"v" : {"type" : "string" , "const" : "fixed" }}, required = ["v" ]),
438+ AsyncMock (), lambda : "token" ,
439+ )
440+ schema = lc_tool .args_schema .model_json_schema ()
441+ assert schema ["properties" ]["v" ]["const" ] == "fixed"
442+
443+ def test_multiple_extra_keys_coexist (self ):
444+ lc_tool = mcp_tool_to_langchain (
445+ self ._tool (
446+ {"s" : {"type" : "string" , "enum" : ["a" , "b" ], "format" : "uuid" , "example" : "a" }},
447+ required = ["s" ],
448+ ),
449+ AsyncMock (), lambda : "token" ,
450+ )
451+ schema = lc_tool .args_schema .model_json_schema ()
452+ assert schema ["properties" ]["s" ]["enum" ] == ["a" , "b" ]
453+ assert schema ["properties" ]["s" ]["format" ] == "uuid"
454+ assert schema ["properties" ]["s" ]["example" ] == "a"
455+
456+ def test_missing_metadata_produces_no_extra (self ):
457+ lc_tool = mcp_tool_to_langchain (
458+ self ._tool ({"id" : {"type" : "string" }}, required = ["id" ]),
459+ AsyncMock (), lambda : "token" ,
460+ )
461+ field = _schema_fields (lc_tool )["id" ]
462+ assert field .description is None
463+ assert field .json_schema_extra is None
464+
465+ def test_unknown_keys_are_silently_ignored (self ):
466+ """Keys not in either bucket (e.g. future JSON Schema extensions) must not raise."""
467+ lc_tool = mcp_tool_to_langchain (
468+ self ._tool ({"x" : {"type" : "string" , "x-custom-ext" : "value" }}, required = ["x" ]),
469+ AsyncMock (), lambda : "token" ,
470+ )
471+ assert "x" in _schema_fields (lc_tool )
472+
473+
319474class TestMcpToolToLangchainInvocation :
320475 """End-to-end invocation tests: verify what actually reaches call_tool."""
321476
0 commit comments