hopeful0 commited on
Commit
b140934
·
unverified ·
1 Parent(s): 0345194

Add StatefulProxyClient (#1109)

Browse files
src/fastmcp/server/proxy.py CHANGED
@@ -580,3 +580,45 @@ class ProxyClient(Client[ClientTransportT]):
580
  """
581
  ctx = get_context()
582
  await ctx.report_progress(progress, total, message)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
580
  """
581
  ctx = get_context()
582
  await ctx.report_progress(progress, total, message)
583
+
584
+
585
+ class StatefulProxyClient(ProxyClient[ClientTransportT]):
586
+ """
587
+ A proxy client that provides a stateful client factory for the proxy server.
588
+
589
+ The stateful proxy client bound its copy to the server session.
590
+ And it will be disconnected when the session is exited.
591
+
592
+ This is useful to proxy a stateful mcp server such as the Playwright MCP server.
593
+ Note that it is essential to ensure that the proxy server itself is also stateful.
594
+ """
595
+
596
+ async def __aexit__(self, exc_type, exc_value, traceback) -> None:
597
+ """
598
+ The stateful proxy client will be forced disconnected when the session is exited.
599
+ So we do nothing here.
600
+ """
601
+ pass
602
+
603
+ def new_stateful(self) -> Client[ClientTransportT]:
604
+ """
605
+ Create a new stateful proxy client instance with the same configuration.
606
+
607
+ Use this method as the client factory for stateful proxy server.
608
+ """
609
+ session = get_context().session
610
+ proxy_client = session.__dict__.get("_proxy_client", None)
611
+
612
+ if proxy_client is None:
613
+ proxy_client = self.new()
614
+ logger.debug(f"{proxy_client} created for {session}")
615
+ session.__dict__["_proxy_client"] = proxy_client
616
+
617
+ async def _on_session_exit():
618
+ proxy_client: Client = session.__dict__.pop("_proxy_client")
619
+ logger.debug(f"{proxy_client} will be disconnect")
620
+ await proxy_client._disconnect(force=True)
621
+
622
+ session._exit_stack.push_async_callback(_on_session_exit)
623
+
624
+ return proxy_client
tests/server/proxy/test_stateful_proxy_client.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+
3
+ import pytest
4
+ from anyio import create_task_group
5
+ from mcp.types import LoggingLevel
6
+
7
+ from fastmcp import Client, Context, FastMCP
8
+ from fastmcp.client.logging import LogMessage
9
+ from fastmcp.client.transports import FastMCPTransport
10
+ from fastmcp.exceptions import ToolError
11
+ from fastmcp.server.proxy import FastMCPProxy, StatefulProxyClient
12
+ from fastmcp.utilities.tests import find_available_port
13
+
14
+
15
+ @pytest.fixture
16
+ def fastmcp_server():
17
+ mcp = FastMCP("TestServer")
18
+
19
+ states: dict[int, int] = {}
20
+
21
+ @mcp.tool
22
+ async def log(
23
+ message: str, level: LoggingLevel, logger: str, context: Context
24
+ ) -> None:
25
+ await context.log(message=message, level=level, logger_name=logger)
26
+
27
+ @mcp.tool
28
+ async def stateful_put(value: int, context: Context) -> None:
29
+ """put a value associated with the server session"""
30
+ key = id(context.session)
31
+ states[key] = value
32
+
33
+ @mcp.tool
34
+ async def stateful_get(context: Context) -> int:
35
+ """get the value associated with the server session"""
36
+ key = id(context.session)
37
+ try:
38
+ return states[key]
39
+ except KeyError:
40
+ raise ToolError("Value not found")
41
+
42
+ return mcp
43
+
44
+
45
+ @pytest.fixture
46
+ async def stateful_proxy_server(fastmcp_server: FastMCP):
47
+ client = StatefulProxyClient(transport=FastMCPTransport(fastmcp_server))
48
+ return FastMCPProxy(client_factory=client.new_stateful)
49
+
50
+
51
+ @pytest.fixture
52
+ async def stateless_server(stateful_proxy_server: FastMCP):
53
+ port = find_available_port()
54
+ url = f"http://127.0.0.1:{port}/mcp/"
55
+
56
+ task = asyncio.create_task(
57
+ stateful_proxy_server.run_http_async(
58
+ host="127.0.0.1", port=port, stateless_http=True
59
+ )
60
+ )
61
+ async with Client(transport=url) as client:
62
+ assert await client.ping()
63
+ yield url
64
+ task.cancel()
65
+ try:
66
+ await task
67
+ except asyncio.CancelledError:
68
+ pass
69
+
70
+
71
+ class TestStatefulProxyClient:
72
+ async def test_concurrent_log_requests_no_mixing(
73
+ self, stateful_proxy_server: FastMCP
74
+ ):
75
+ """Test that concurrent log requests don't mix handlers (fixes #1068)."""
76
+ results: dict[str, LogMessage] = {}
77
+
78
+ async def log_handler_a(message: LogMessage) -> None:
79
+ results["logger_a"] = message
80
+
81
+ async def log_handler_b(message: LogMessage) -> None:
82
+ results["logger_b"] = message
83
+
84
+ async with (
85
+ Client(stateful_proxy_server, log_handler=log_handler_a) as client_a,
86
+ Client(stateful_proxy_server, log_handler=log_handler_b) as client_b,
87
+ ):
88
+ async with create_task_group() as tg:
89
+ tg.start_soon(
90
+ client_a.call_tool,
91
+ "log",
92
+ {"message": "Hello, world!", "level": "info", "logger": "a"},
93
+ )
94
+ tg.start_soon(
95
+ client_b.call_tool,
96
+ "log",
97
+ {"message": "Hello, world!", "level": "info", "logger": "b"},
98
+ )
99
+
100
+ assert results["logger_a"].logger == "a"
101
+ assert results["logger_b"].logger == "b"
102
+
103
+ async def test_stateful_proxy(self, stateful_proxy_server: FastMCP):
104
+ """Test that the state shared across multiple calls for the same client (fixes #959)."""
105
+ async with Client(stateful_proxy_server) as client:
106
+ with pytest.raises(ToolError, match="Value not found"):
107
+ await client.call_tool("stateful_get", {})
108
+
109
+ await client.call_tool("stateful_put", {"value": 1})
110
+ result = await client.call_tool("stateful_get", {})
111
+ assert result.data == 1
112
+
113
+ async def test_stateless_proxy(self, stateless_server: str):
114
+ """Test that the state will not be shared across different calls,
115
+ even if they are from the same client."""
116
+ async with Client(stateless_server) as client:
117
+ await client.call_tool("stateful_put", {"value": 1})
118
+
119
+ with pytest.raises(ToolError, match="Value not found"):
120
+ await client.call_tool("stateful_get", {})