diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index fc1c5f2b3..2592873d7 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -54,6 +54,7 @@ from .transports import ( PythonStdioTransport, SessionKwargs, SSETransport, + StdioTransport, StreamableHttpTransport, infer_transport, ) @@ -337,11 +338,11 @@ class Client(Generic[ClientTransportT]): await fresh_client.call_tool("some_tool", {}) ``` """ - new_client = copy.copy(self) - # Reset session state to fresh state - new_client._session_state = ClientSessionState() + if not isinstance(self.transport, StdioTransport): + # Reset session state to fresh state + new_client._session_state = ClientSessionState() new_client.name += f":{secrets.token_hex(2)}" @@ -394,6 +395,7 @@ class Client(Generic[ClientTransportT]): self._session_state.session_task is None or self._session_state.session_task.done() ) + if need_to_start: if self._session_state.nesting_counter != 0: raise RuntimeError( @@ -419,6 +421,7 @@ class Client(Generic[ClientTransportT]): ) from exception self._session_state.nesting_counter += 1 + return self async def _disconnect(self, force: bool = False): diff --git a/src/fastmcp/utilities/mcp_config.py b/src/fastmcp/utilities/mcp_config.py index ef4da8a78..8f22c8b92 100644 --- a/src/fastmcp/utilities/mcp_config.py +++ b/src/fastmcp/utilities/mcp_config.py @@ -51,6 +51,7 @@ def mcp_server_type_to_servers_and_transports( client: ProxyClient[StreamableHttpTransport | SSETransport | StdioTransport] = ( ProxyClient(transport=transport, name=client_name) ) + server = FastMCP.as_proxy(name=server_name, backend=client) return name, server, transport diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 3a45ddd0d..a9471d79b 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -7,7 +7,7 @@ import weakref import psutil import pytest -from fastmcp import Client +from fastmcp import Client, FastMCP from fastmcp.client.transports import PythonStdioTransport, StdioTransport @@ -24,6 +24,44 @@ def gc_collect_harder(): gc.collect() +class TestParallelCalls: + @pytest.fixture + def stdio_script(self, tmp_path): + script = inspect.cleandoc(''' + import os + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def pid() -> int: + """Gets PID of server""" + return os.getpid() + + if __name__ == "__main__": + mcp.run() + ''') + script_file = tmp_path / "stdio.py" + script_file.write_text(script) + return script_file + + async def test_parallel_calls(self, stdio_script): + backend_transport = PythonStdioTransport(script_path=stdio_script) + backend_client = Client(transport=backend_transport) + + proxy = FastMCP.as_proxy(backend=backend_client, name="PROXY") + + count = 10 + + tasks = [proxy.get_tools() for _ in range(count)] + + results = await asyncio.gather(*tasks, return_exceptions=True) + + assert len(results) == count + errors = [result for result in results if isinstance(result, Exception)] + assert len(errors) == 0 + + class TestKeepAlive: # https://github.com/jlowin/fastmcp/issues/581 diff --git a/tests/test_mcp_config.py b/tests/test_mcp_config.py index f8e396b4f..1500a7ddd 100644 --- a/tests/test_mcp_config.py +++ b/tests/test_mcp_config.py @@ -243,6 +243,50 @@ async def test_multi_client(tmp_path: Path): assert result_2.data == 3 +async def test_multi_client_parallel_calls(tmp_path: Path): + server_script = inspect.cleandoc(""" + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool + def add(a: int, b: int) -> int: + return a + b + + if __name__ == '__main__': + mcp.run() + """) + + script_path = tmp_path / "test.py" + script_path.write_text(server_script) + + config = { + "mcpServers": { + "test_1": { + "command": "python", + "args": [str(script_path)], + }, + "test_2": { + "command": "python", + "args": [str(script_path)], + }, + } + } + + client = Client(config) + + async with client: + _ = await client.list_tools() + + tasks = [client.list_tools() for _ in range(40)] + + results = await asyncio.gather(*tasks, return_exceptions=True) + exceptions = [result for result in results if isinstance(result, Exception)] + assert len(exceptions) == 0 + assert len(results) == 40 + assert all(len(result) == 2 for result in results) + + @pytest.mark.skipif( running_under_debugger() or sys.platform.startswith("win32"), reason="Debugger holds a reference to the transport; Windows has process lifecycle issues",