diff --git a/src/mcpadapt/__init__.py b/src/mcpadapt/__init__.py index c11f861..569b121 100644 --- a/src/mcpadapt/__init__.py +++ b/src/mcpadapt/__init__.py @@ -1 +1 @@ -__version__ = "0.1.9" +__version__ = "0.1.10" diff --git a/src/mcpadapt/core.py b/src/mcpadapt/core.py index 7ae969a..5790f99 100644 --- a/src/mcpadapt/core.py +++ b/src/mcpadapt/core.py @@ -176,6 +176,7 @@ def __init__( | list[StdioServerParameters | dict[str, Any]], adapter: ToolAdapter, connect_timeout: int = 30, + client_session_timeout_seconds: float | timedelta | None = 5, ): """ Manage the MCP server / client lifecycle and expose tools adapted with the adapter. @@ -185,6 +186,7 @@ def __init__( MCP server parameters (stdio or sse). Can be a list if you want to connect multiple MCPs at once. adapter (ToolAdapter): Adapter to use to convert MCP tools call into agentic framework tools. connect_timeout (int): Connection timeout in seconds to the mcp server (default is 30s). + client_session_timeout_seconds: Timeout for MCP ClientSession calls Raises: TimeoutError: When the connection to the mcp server time out. @@ -209,6 +211,7 @@ def __init__( self.thread = threading.Thread(target=self._run_loop, daemon=True) self.connect_timeout = connect_timeout + self.client_session_timeout_seconds = client_session_timeout_seconds def _run_loop(self): """Runs the event loop in a separate thread (for synchronous usage).""" @@ -217,7 +220,9 @@ def _run_loop(self): async def setup(): async with AsyncExitStack() as stack: connections = [ - await stack.enter_async_context(mcptools(params)) + await stack.enter_async_context( + mcptools(params, self.client_session_timeout_seconds) + ) for params in self.serverparams ] self.sessions, self.mcp_tools = [list(c) for c in zip(*connections)] @@ -323,7 +328,9 @@ async def __aenter__(self) -> list[Any]: self._ctxmanager = AsyncExitStack() connections = [ - await self._ctxmanager.enter_async_context(mcptools(params)) + await self._ctxmanager.enter_async_context( + mcptools(params, self.client_session_timeout_seconds) + ) for params in self.serverparams ] 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_core.py b/tests/test_core.py index 98ba851..f66170e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -153,6 +153,28 @@ async def echo_streamable_http_server(echo_server_streamable_http_script): process.wait() +@pytest.fixture +def slow_start_server_script(): + return dedent( + ''' + import time + from mcp.server.fastmcp import FastMCP + + # Sleep for 2 seconds to simulate slow startup + time.sleep(2) + + mcp = FastMCP("Slow Server") + + @mcp.tool() + def echo_tool(text: str) -> str: + """Echo the input text""" + return f"Echo: {text}" + + mcp.run() + ''' + ) + + def test_basic_sync(echo_server_script): with MCPAdapt( StdioServerParameters( @@ -319,3 +341,63 @@ async def test_basic_async_streamable_http(echo_streamable_http_server): ) as tools: assert len(tools) == 1 assert (await tools[0]({"text": "hello"})).content[0].text == "Echo: hello" + + +def test_connect_timeout(slow_start_server_script): + """Test that connect_timeout raises TimeoutError when server starts slowly""" + with pytest.raises( + TimeoutError, match="Couldn't connect to the MCP server after 1 seconds" + ): + with MCPAdapt( + StdioServerParameters( + command="uv", args=["run", "python", "-c", slow_start_server_script] + ), + DummyAdapter(), + connect_timeout=1, # 1 second timeout, server takes 2 seconds to start + ): + pass + + +def test_client_session_timeout_parameter_propagation(echo_server_script): + """Test that client_session_timeout_seconds parameter is properly stored and accessible""" + from datetime import timedelta + + # Test with float value + adapter_float = MCPAdapt( + StdioServerParameters( + command="uv", args=["run", "python", "-c", echo_server_script] + ), + DummyAdapter(), + client_session_timeout_seconds=2.5, + ) + assert adapter_float.client_session_timeout_seconds == 2.5 + + # Test with timedelta value + timeout_td = timedelta(seconds=3.0) + adapter_td = MCPAdapt( + StdioServerParameters( + command="uv", args=["run", "python", "-c", echo_server_script] + ), + DummyAdapter(), + client_session_timeout_seconds=timeout_td, + ) + assert adapter_td.client_session_timeout_seconds == timeout_td + + # Test with None value + adapter_none = MCPAdapt( + StdioServerParameters( + command="uv", args=["run", "python", "-c", echo_server_script] + ), + DummyAdapter(), + client_session_timeout_seconds=None, + ) + assert adapter_none.client_session_timeout_seconds is None + + # Test default value + adapter_default = MCPAdapt( + StdioServerParameters( + command="uv", args=["run", "python", "-c", echo_server_script] + ), + DummyAdapter(), + ) + assert adapter_default.client_session_timeout_seconds == 5 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