diff --git a/src/mcpadapt/langchain_adapter.py b/src/mcpadapt/langchain_adapter.py index fabc4db..db3c7fd 100644 --- a/src/mcpadapt/langchain_adapter.py +++ b/src/mcpadapt/langchain_adapter.py @@ -84,7 +84,38 @@ def _generate_tool_class( # TODO: this could be better and handle nested objects... tool_params = [] for k, v in properties.items(): - tool_params.append(f"{k}: {JSON_SCHEMA_TO_PYTHON_TYPES[v['type']]}") + # Handle case where 'type' is missing but 'anyOf' is present (for multiple types) + if "type" in v: + if isinstance(v["type"], list): + # Handle list-type (multiple allowed types in JSON Schema) + types = [] + for t in v["type"]: + if t != "null": # Exclude null types + types.append(JSON_SCHEMA_TO_PYTHON_TYPES[t]) + + if len(types) > 1: + python_type = " | ".join(types) + else: + python_type = types[0] if types else "str" # Default to str + else: + python_type = JSON_SCHEMA_TO_PYTHON_TYPES[v["type"]] + elif "anyOf" in v: + # Extract types from anyOf + types = [] + for option in v["anyOf"]: + if "type" in option and option["type"] != "null": + types.append(JSON_SCHEMA_TO_PYTHON_TYPES[option["type"]]) + + if len(types) > 1: + python_type = " | ".join(types) + else: + python_type = types[0] if types else "str" # Default to str + else: + # Default to str if no type information is available + python_type = "str" + + tool_params.append(f"{k}: {python_type}") + tool_params = ", ".join(tool_params) argument = "{" + ", ".join(f"'{k}': {k}" for k in properties.keys()) + "}" diff --git a/src/mcpadapt/utils/modeling.py b/src/mcpadapt/utils/modeling.py index af15896..b34fc81 100644 --- a/src/mcpadapt/utils/modeling.py +++ b/src/mcpadapt/utils/modeling.py @@ -157,7 +157,23 @@ def get_field_type(field_name: str, field_schema: Dict[str, Any], required: set) else: # Simple types json_type = field_schema.get("type", "string") - field_type = json_type_mapping.get(json_type, Any) # type: ignore + + # Handle list-type (multiple allowed types in JSON Schema) + if isinstance(json_type, list): + # Convert to Union type (consistent with anyOf handling) + types = [] + for t in json_type: + if t != "null": # Exclude null types as in anyOf handling + mapped_type = json_type_mapping.get(t, Any) + types.append(mapped_type) + + if len(types) > 1: + field_type = Union[tuple(types)] # type: ignore + else: + field_type = types[0] if types else Any + else: + # Original code for simple types + field_type = json_type_mapping.get(json_type, Any) # type: ignore # Handle optionality and default values default = field_schema.get("default") diff --git a/tests/test_langchain_adapter.py b/tests/test_langchain_adapter.py index ab8d93b..e18c55a 100644 --- a/tests/test_langchain_adapter.py +++ b/tests/test_langchain_adapter.py @@ -7,6 +7,32 @@ from mcpadapt.langchain_adapter import LangChainAdapter +@pytest.fixture +def json_schema_array_type_server_script(): + """ + Create a server with a tool that uses array notation for type fields. + This tests handling of JSON Schema 'type': ['string', 'number'] syntax. + """ + return dedent( + ''' + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("JSON Schema Array Type Test Server") + + @mcp.tool() + def multi_type_tool( + id: str | int, # This becomes {"type": ["string", "number"]} in JSON Schema + name: str | None = None, # Tests nullable with array type + ) -> str: + """Test tool with a parameter that accepts multiple types using array notation""" + id_type = type(id).__name__ + return f"Received ID: {id} (type: {id_type}), Name: {name}" + + mcp.run() + ''' + ) + + @pytest.fixture def echo_server_script(): return dedent( @@ -112,6 +138,31 @@ def test_basic_sync_sse(echo_sse_server): assert len(tools) == 1 +def test_json_schema_array_type_handling(json_schema_array_type_server_script): + """ + Test that MCPAdapt correctly handles JSON Schema with array notation for types. + This ensures our fix for 'unhashable type: list' error is working. + """ + with MCPAdapt( + StdioServerParameters( + command="uv", + args=["run", "python", "-c", json_schema_array_type_server_script], + ), + LangChainAdapter(), + ) as tools: + # Verify the tool was successfully loaded + assert len(tools) == 1 + assert tools[0].name == "multi_type_tool" + + # Test with string type + result_string = tools[0].invoke({"id": "abc123", "name": "test"}) + assert "Received ID: abc123 (type: str)" in result_string + + # Test with integer type + result_int = tools[0].invoke({"id": 42, "name": "test"}) + assert "Received ID: 42 (type: int)" in result_int + + def test_tool_name_with_dashes(): mcp_server_script = dedent( ''' diff --git a/tests/utils/test_modeling.py b/tests/utils/test_modeling.py new file mode 100644 index 0000000..5173418 --- /dev/null +++ b/tests/utils/test_modeling.py @@ -0,0 +1,37 @@ +""" +Tests for the modeling module, specifically focused on JSON Schema handling. +""" + +from mcpadapt.utils.modeling import create_model_from_json_schema + + +def test_direct_modeling_with_list_type(): + """ + Test the modeling module directly with a schema using list-type notation. + This test is specifically designed to verify handling of list-type JSON Schema fields. + """ + # Create a schema with list-type field + schema = { + "type": "object", + "properties": { + "multi_type_field": { + "type": ["string", "number"], + "description": "Field that accepts multiple types", + }, + "nullable_field": { + "type": ["string", "null"], + "description": "Field that is nullable", + }, + }, + } + + # Create model from schema - should not raise TypeError + model = create_model_from_json_schema(schema) + + # Verify the model works as expected with string + instance = model(multi_type_field="test") + assert instance.multi_type_field == "test" + + # Verify the model works as expected with number + instance = model(multi_type_field=42) + assert instance.multi_type_field == 42