Jeremiah Lowin commited on
Commit
13c4937
·
unverified ·
2 Parent(s): 8e65d3174262ae

Merge pull request #635 from Sillocan/main

Browse files

fix: Support concurrency in FastMcpProxy (and Client)

src/fastmcp/client/client.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import datetime
2
  from contextlib import AsyncExitStack, asynccontextmanager
3
  from pathlib import Path
@@ -152,6 +153,10 @@ class Client(Generic[ClientTransportT]):
152
  self._session: ClientSession | None = None
153
  self._exit_stack: AsyncExitStack | None = None
154
  self._nesting_counter: int = 0
 
 
 
 
155
  self._initialize_result: mcp.types.InitializeResult | None = None
156
 
157
  if log_handler is None:
@@ -191,6 +196,7 @@ class Client(Generic[ClientTransportT]):
191
  self._session_kwargs["sampling_callback"] = create_sampling_callback(
192
  sampling_handler
193
  )
 
194
 
195
  @property
196
  def session(self) -> ClientSession:
@@ -242,34 +248,45 @@ class Client(Generic[ClientTransportT]):
242
  except TimeoutError:
243
  raise RuntimeError("Failed to initialize server session")
244
  finally:
245
- self._exit_stack = None
246
  self._session = None
247
  self._initialize_result = None
248
 
249
  async def __aenter__(self):
250
- if self._nesting_counter == 0:
251
- # Create exit stack to manage both context managers
252
- stack = AsyncExitStack()
253
- await stack.__aenter__()
254
-
255
- await stack.enter_async_context(self._context_manager())
256
-
257
- self._exit_stack = stack
258
-
259
- self._nesting_counter += 1
260
-
261
  return self
262
 
263
  async def __aexit__(self, exc_type, exc_val, exc_tb):
264
- self._nesting_counter -= 1
265
-
266
- if self._nesting_counter == 0:
267
- # Exit the stack which will handle cleaning up the session
268
- if self._exit_stack is not None:
269
- try:
270
- await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
271
- finally:
272
- self._exit_stack = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
  async def close(self):
275
  await self.transport.close()
 
1
+ import asyncio
2
  import datetime
3
  from contextlib import AsyncExitStack, asynccontextmanager
4
  from pathlib import Path
 
153
  self._session: ClientSession | None = None
154
  self._exit_stack: AsyncExitStack | None = None
155
  self._nesting_counter: int = 0
156
+ self._context_lock = anyio.Lock()
157
+ self._session_task: asyncio.Task | None = None
158
+ self._ready_event = asyncio.Event()
159
+ self._stop_event = asyncio.Event()
160
  self._initialize_result: mcp.types.InitializeResult | None = None
161
 
162
  if log_handler is None:
 
196
  self._session_kwargs["sampling_callback"] = create_sampling_callback(
197
  sampling_handler
198
  )
199
+ # self._session_manager = self._context_manager()
200
 
201
  @property
202
  def session(self) -> ClientSession:
 
248
  except TimeoutError:
249
  raise RuntimeError("Failed to initialize server session")
250
  finally:
 
251
  self._session = None
252
  self._initialize_result = None
253
 
254
  async def __aenter__(self):
255
+ async with self._context_lock:
256
+ need_to_start = self._session_task is None or self._session_task.done()
257
+ if need_to_start:
258
+ self._stop_event = anyio.Event()
259
+ self._ready_event = anyio.Event()
260
+ self._session_task = asyncio.create_task(self._session_runner())
261
+ await self._ready_event.wait()
262
+ self._nesting_counter += 1
 
 
 
263
  return self
264
 
265
  async def __aexit__(self, exc_type, exc_val, exc_tb):
266
+ async with self._context_lock:
267
+ self._nesting_counter -= 1
268
+ if self._nesting_counter != 0:
269
+ return
270
+ self._stop_event.set()
271
+ runner_task = self._session_task
272
+ self._session_task = None
273
+ if runner_task:
274
+ await runner_task
275
+ # Reset for future reconnects
276
+ self._stop_event = anyio.Event()
277
+ self._ready_event = anyio.Event()
278
+
279
+ async def _session_runner(self):
280
+ async with AsyncExitStack() as stack:
281
+ try:
282
+ await stack.enter_async_context(self._context_manager())
283
+ # Session/context is now ready
284
+ self._ready_event.set()
285
+ # Wait until disconnect/stop is requested
286
+ await self._stop_event.wait()
287
+ finally:
288
+ # On exit, ensure ready event is set (idempotent)
289
+ self._ready_event.set()
290
 
291
  async def close(self):
292
  await self.transport.close()
tests/server/test_proxy.py CHANGED
@@ -3,6 +3,7 @@ from typing import Any
3
 
4
  import mcp.types
5
  import pytest
 
6
  from dirty_equals import Contains
7
  from mcp import McpError
8
 
@@ -242,3 +243,26 @@ class TestPrompts:
242
  assert result.messages[0].role == "user"
243
  assert isinstance(result.messages[0].content, mcp.types.TextContent)
244
  assert result.messages[0].content.text == "Welcome to FastMCP, Alice!"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  import mcp.types
5
  import pytest
6
+ from anyio import create_task_group
7
  from dirty_equals import Contains
8
  from mcp import McpError
9
 
 
243
  assert result.messages[0].role == "user"
244
  assert isinstance(result.messages[0].content, mcp.types.TextContent)
245
  assert result.messages[0].content.text == "Welcome to FastMCP, Alice!"
246
+
247
+
248
+ async def test_proxy_handles_multiple_concurrent_tasks_correctly(
249
+ proxy_server: FastMCPProxy,
250
+ ):
251
+ results = {}
252
+
253
+ async def get_and_store(name, coro):
254
+ results[name] = await coro()
255
+
256
+ async with create_task_group() as tg:
257
+ tg.start_soon(get_and_store, "prompts", proxy_server.get_prompts)
258
+ tg.start_soon(get_and_store, "resources", proxy_server.get_resources)
259
+ tg.start_soon(get_and_store, "tools", proxy_server.get_tools)
260
+
261
+ assert list(results) == Contains("resources", "prompts", "tools")
262
+ assert list(results["prompts"]) == Contains("welcome")
263
+ assert [r.name for r in results["resources"].values()] == Contains(
264
+ "data://users", "resource://wave"
265
+ )
266
+ assert list(results["tools"]) == Contains(
267
+ "greet", "add", "error_tool", "tool_without_description"
268
+ )