Spaces:
Running
Running
Merge pull request #635 from Sillocan/main
Browse filesfix: Support concurrency in FastMcpProxy (and Client)
- src/fastmcp/client/client.py +38 -21
- tests/server/test_proxy.py +24 -0
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 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
self.
|
| 258 |
-
|
| 259 |
-
self._nesting_counter += 1
|
| 260 |
-
|
| 261 |
return self
|
| 262 |
|
| 263 |
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
)
|