bianning bianning commited on
Commit
0d61263
·
unverified ·
1 Parent(s): 1807d5c

Add async support to client_factory in FastMCPProxy (#1286) (#1375)

Browse files
src/fastmcp/server/proxy.py CHANGED
@@ -1,7 +1,8 @@
1
  from __future__ import annotations
2
 
 
3
  import warnings
4
- from collections.abc import Callable
5
  from pathlib import Path
6
  from typing import TYPE_CHECKING, Any, cast
7
  from urllib.parse import quote
@@ -48,11 +49,27 @@ if TYPE_CHECKING:
48
 
49
  logger = get_logger(__name__)
50
 
 
 
51
 
52
- class ProxyToolManager(ToolManager):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  """A ToolManager that sources its tools from a remote client in addition to local and mounted tools."""
54
 
55
- def __init__(self, client_factory: Callable[[], Client], **kwargs):
56
  super().__init__(**kwargs)
57
  self.client_factory = client_factory
58
 
@@ -63,7 +80,7 @@ class ProxyToolManager(ToolManager):
63
 
64
  # Then add proxy tools, but don't overwrite existing ones
65
  try:
66
- client = self.client_factory()
67
  async with client:
68
  client_tools = await client.list_tools()
69
  for tool in client_tools:
@@ -94,7 +111,7 @@ class ProxyToolManager(ToolManager):
94
  return await super().call_tool(key, arguments)
95
  except NotFoundError:
96
  # If not found locally, try proxy
97
- client = self.client_factory()
98
  async with client:
99
  result = await client.call_tool(key, arguments)
100
  return ToolResult(
@@ -103,10 +120,10 @@ class ProxyToolManager(ToolManager):
103
  )
104
 
105
 
106
- class ProxyResourceManager(ResourceManager):
107
  """A ResourceManager that sources its resources from a remote client in addition to local and mounted resources."""
108
 
109
- def __init__(self, client_factory: Callable[[], Client], **kwargs):
110
  super().__init__(**kwargs)
111
  self.client_factory = client_factory
112
 
@@ -117,7 +134,7 @@ class ProxyResourceManager(ResourceManager):
117
 
118
  # Then add proxy resources, but don't overwrite existing ones
119
  try:
120
- client = self.client_factory()
121
  async with client:
122
  client_resources = await client.list_resources()
123
  for resource in client_resources:
@@ -140,7 +157,7 @@ class ProxyResourceManager(ResourceManager):
140
 
141
  # Then add proxy templates, but don't overwrite existing ones
142
  try:
143
- client = self.client_factory()
144
  async with client:
145
  client_templates = await client.list_resource_templates()
146
  for template in client_templates:
@@ -173,7 +190,7 @@ class ProxyResourceManager(ResourceManager):
173
  return await super().read_resource(uri)
174
  except NotFoundError:
175
  # If not found locally, try proxy
176
- client = self.client_factory()
177
  async with client:
178
  result = await client.read_resource(uri)
179
  if isinstance(result[0], TextResourceContents):
@@ -184,10 +201,10 @@ class ProxyResourceManager(ResourceManager):
184
  raise ResourceError(f"Unsupported content type: {type(result[0])}")
185
 
186
 
187
- class ProxyPromptManager(PromptManager):
188
  """A PromptManager that sources its prompts from a remote client in addition to local and mounted prompts."""
189
 
190
- def __init__(self, client_factory: Callable[[], Client], **kwargs):
191
  super().__init__(**kwargs)
192
  self.client_factory = client_factory
193
 
@@ -198,7 +215,7 @@ class ProxyPromptManager(PromptManager):
198
 
199
  # Then add proxy prompts, but don't overwrite existing ones
200
  try:
201
- client = self.client_factory()
202
  async with client:
203
  client_prompts = await client.list_prompts()
204
  for prompt in client_prompts:
@@ -230,7 +247,7 @@ class ProxyPromptManager(PromptManager):
230
  return await super().render_prompt(name, arguments)
231
  except NotFoundError:
232
  # If not found locally, try proxy
233
- client = self.client_factory()
234
  async with client:
235
  result = await client.get_prompt(name, arguments)
236
  return result
@@ -444,7 +461,7 @@ class FastMCPProxy(FastMCP):
444
  self,
445
  client: Client | None = None,
446
  *,
447
- client_factory: Callable[[], Client] | None = None,
448
  **kwargs,
449
  ):
450
  """
@@ -459,6 +476,7 @@ class FastMCPProxy(FastMCP):
459
  created that provides session isolation for backwards compatibility.
460
  client_factory: A callable that returns a Client instance when called.
461
  This gives you full control over session creation and reuse.
 
462
  **kwargs: Additional settings for the FastMCP server.
463
  """
464
 
 
1
  from __future__ import annotations
2
 
3
+ import inspect
4
  import warnings
5
+ from collections.abc import Awaitable, Callable
6
  from pathlib import Path
7
  from typing import TYPE_CHECKING, Any, cast
8
  from urllib.parse import quote
 
49
 
50
  logger = get_logger(__name__)
51
 
52
+ # Type alias for client factory functions
53
+ ClientFactoryT = Callable[[], Client] | Callable[[], Awaitable[Client]]
54
 
55
+
56
+ class ProxyManagerMixin:
57
+ """A mixin for proxy managers to provide a unified client retrieval method."""
58
+
59
+ client_factory: ClientFactoryT
60
+
61
+ async def _get_client(self) -> Client:
62
+ """Gets a client instance by calling the sync or async factory."""
63
+ client = self.client_factory()
64
+ if inspect.isawaitable(client):
65
+ client = await client
66
+ return client
67
+
68
+
69
+ class ProxyToolManager(ToolManager, ProxyManagerMixin):
70
  """A ToolManager that sources its tools from a remote client in addition to local and mounted tools."""
71
 
72
+ def __init__(self, client_factory: ClientFactoryT, **kwargs):
73
  super().__init__(**kwargs)
74
  self.client_factory = client_factory
75
 
 
80
 
81
  # Then add proxy tools, but don't overwrite existing ones
82
  try:
83
+ client = await self._get_client()
84
  async with client:
85
  client_tools = await client.list_tools()
86
  for tool in client_tools:
 
111
  return await super().call_tool(key, arguments)
112
  except NotFoundError:
113
  # If not found locally, try proxy
114
+ client = await self._get_client()
115
  async with client:
116
  result = await client.call_tool(key, arguments)
117
  return ToolResult(
 
120
  )
121
 
122
 
123
+ class ProxyResourceManager(ResourceManager, ProxyManagerMixin):
124
  """A ResourceManager that sources its resources from a remote client in addition to local and mounted resources."""
125
 
126
+ def __init__(self, client_factory: ClientFactoryT, **kwargs):
127
  super().__init__(**kwargs)
128
  self.client_factory = client_factory
129
 
 
134
 
135
  # Then add proxy resources, but don't overwrite existing ones
136
  try:
137
+ client = await self._get_client()
138
  async with client:
139
  client_resources = await client.list_resources()
140
  for resource in client_resources:
 
157
 
158
  # Then add proxy templates, but don't overwrite existing ones
159
  try:
160
+ client = await self._get_client()
161
  async with client:
162
  client_templates = await client.list_resource_templates()
163
  for template in client_templates:
 
190
  return await super().read_resource(uri)
191
  except NotFoundError:
192
  # If not found locally, try proxy
193
+ client = await self._get_client()
194
  async with client:
195
  result = await client.read_resource(uri)
196
  if isinstance(result[0], TextResourceContents):
 
201
  raise ResourceError(f"Unsupported content type: {type(result[0])}")
202
 
203
 
204
+ class ProxyPromptManager(PromptManager, ProxyManagerMixin):
205
  """A PromptManager that sources its prompts from a remote client in addition to local and mounted prompts."""
206
 
207
+ def __init__(self, client_factory: ClientFactoryT, **kwargs):
208
  super().__init__(**kwargs)
209
  self.client_factory = client_factory
210
 
 
215
 
216
  # Then add proxy prompts, but don't overwrite existing ones
217
  try:
218
+ client = await self._get_client()
219
  async with client:
220
  client_prompts = await client.list_prompts()
221
  for prompt in client_prompts:
 
247
  return await super().render_prompt(name, arguments)
248
  except NotFoundError:
249
  # If not found locally, try proxy
250
+ client = await self._get_client()
251
  async with client:
252
  result = await client.get_prompt(name, arguments)
253
  return result
 
461
  self,
462
  client: Client | None = None,
463
  *,
464
+ client_factory: ClientFactoryT | None = None,
465
  **kwargs,
466
  ):
467
  """
 
476
  created that provides session isolation for backwards compatibility.
477
  client_factory: A callable that returns a Client instance when called.
478
  This gives you full control over session creation and reuse.
479
+ Can be either a synchronous or asynchronous function.
480
  **kwargs: Additional settings for the FastMCP server.
481
  """
482
 
tests/server/proxy/test_proxy_server.py CHANGED
@@ -1,5 +1,6 @@
 
1
  import json
2
- from typing import Any
3
 
4
  import pytest
5
  from anyio import create_task_group
@@ -109,8 +110,24 @@ def test_as_proxy_with_url():
109
  """FastMCP.as_proxy should accept a URL without connecting."""
110
  proxy = FastMCP.as_proxy("http://example.com/mcp/")
111
  assert isinstance(proxy, FastMCPProxy)
112
- assert isinstance(proxy.client_factory().transport, StreamableHttpTransport)
113
- assert proxy.client_factory().transport.url == "http://example.com/mcp/" # type: ignore[attr-defined]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
 
116
  class TestTools:
 
1
+ import inspect
2
  import json
3
+ from typing import Any, cast
4
 
5
  import pytest
6
  from anyio import create_task_group
 
110
  """FastMCP.as_proxy should accept a URL without connecting."""
111
  proxy = FastMCP.as_proxy("http://example.com/mcp/")
112
  assert isinstance(proxy, FastMCPProxy)
113
+ client = cast(Client, proxy.client_factory())
114
+ assert isinstance(client.transport, StreamableHttpTransport)
115
+ assert client.transport.url == "http://example.com/mcp/" # type: ignore[attr-defined]
116
+
117
+
118
+ async def test_proxy_with_async_client_factory():
119
+ """FastMCPProxy should accept an async client_factory."""
120
+
121
+ async def async_factory():
122
+ return Client("http://example.com/mcp/")
123
+
124
+ proxy = FastMCPProxy(client_factory=async_factory)
125
+ assert isinstance(proxy, FastMCPProxy)
126
+ assert inspect.iscoroutinefunction(proxy.client_factory)
127
+ client = await proxy.client_factory()
128
+ assert isinstance(client, Client)
129
+ assert isinstance(client.transport, StreamableHttpTransport)
130
+ assert client.transport.url == "http://example.com/mcp/" # type: ignore[attr-defined]
131
 
132
 
133
  class TestTools: