Jeremiah Lowin Claude commited on
Commit
c7708b2
·
unverified ·
1 Parent(s): cb2e1c5

Fix concurrent proxy client operations with session isolation (#1083)

Browse files

* Refactor Client session state with ClientSessionState dataclass

Fixes #1068 by introducing ClientSessionState to encapsulate session management
attributes, simplifying Client.new() and preventing concurrent proxy client
context mixing through client factory pattern.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update proxy documentation for new client factory pattern

Documents the new session management behavior, client_factory parameter,
and concurrent operation safety introduced in v2.10.3 to fix #1068.

* Remove deprecation of client parameter and update documentation

- Undeprecated FastMCPProxy client parameter
- Made client_factory the advanced option for custom control
- Added detailed explanation of how client factories work internally
- Removed outdated note about proxy feature limitations
- Removed fabricated Advanced Usage section

* Re-do proxy documentation

* Fix deprecated client parameter to provide session isolation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

docs/servers/proxy.mdx CHANGED
@@ -10,13 +10,10 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
10
 
11
  FastMCP provides a powerful proxying capability that allows one FastMCP server instance to act as a frontend for another MCP server (which could be remote, running on a different transport, or even another FastMCP instance). This is achieved using the `FastMCP.as_proxy()` class method.
12
 
13
- `as_proxy()` accepts either an existing `Client` or any argument that can be passed to a `Client` as its `transport` parameter&mdash;such as another `FastMCP` instance, a URL to a remote server, or an MCP configuration dictionary.
14
-
15
  ## What is Proxying?
16
 
17
  Proxying means setting up a FastMCP server that doesn't implement its own tools or resources directly. Instead, when it receives a request (like `tools/call` or `resources/read`), it forwards that request to a *backend* MCP server, receives the response, and then relays that response back to the original client.
18
 
19
-
20
  ```mermaid
21
  sequenceDiagram
22
  participant ClientApp as Your Client (e.g., Claude Desktop)
@@ -30,81 +27,162 @@ sequenceDiagram
30
  Note over ClientApp, FastMCPProxy: Proxy relays the response
31
  FastMCPProxy-->>ClientApp: MCP Response (e.g. stdio)
32
  ```
33
- ### Use Cases
34
 
35
- - **Transport Bridging**: Expose a server running on one transport (e.g., a remote SSE server) via a different transport (e.g., local Stdio for Claude Desktop).
36
- - **Adding Functionality**: Insert a layer in front of an existing server to add caching, logging, authentication, or modify requests/responses (though direct modification requires subclassing `FastMCPProxy`).
37
- - **Security Boundary**: Use the proxy as a controlled gateway to an internal server.
38
- - **Simplifying Client Configuration**: Provide a single, stable endpoint (the proxy) even if the backend server's location or transport changes.
 
 
 
 
 
 
 
39
 
40
- ## Creating a Proxy
41
 
42
- The easiest way to create a proxy is using the `FastMCP.as_proxy()` class method. This creates a standard FastMCP server that forwards requests to another MCP server.
43
 
44
  ```python
45
  from fastmcp import FastMCP
 
46
 
47
- # Provide the backend in any form accepted by Client
48
- proxy_server = FastMCP.as_proxy(
49
- "backend_server.py", # Could also be a FastMCP instance, config dict, or a remote URL
50
- name="MyProxyServer" # Optional settings for the proxy
51
  )
52
 
53
- # Or create the Client yourself for custom configuration
54
- backend_client = Client("backend_server.py")
55
- proxy_from_client = FastMCP.as_proxy(backend_client)
56
  ```
57
 
58
- **How `as_proxy` Works:**
 
 
 
 
59
 
60
- 1. It connects to the backend server using the provided client.
61
- 2. It discovers all the tools, resources, resource templates, and prompts available on the backend server.
62
- 3. It creates corresponding "proxy" components that forward requests to the backend.
63
- 4. It returns a standard `FastMCP` server instance that can be used like any other.
64
 
65
- <Note>
66
- Currently, proxying focuses primarily on exposing the major MCP objects (tools, resources, templates, and prompts). Some advanced MCP features like notifications and sampling are not fully supported in proxies in the current version. Support for these additional features may be added in future releases.
67
- </Note>
68
 
69
- ### Bridging Transports
70
 
71
- A common use case is to bridge transports. For example, making a remote SSE server available locally via Stdio:
72
 
73
  ```python
74
- from fastmcp import FastMCP
75
 
76
- # Target a remote SSE server directly by URL
77
- proxy = FastMCP.as_proxy("http://example.com/mcp/sse", name="SSE to Stdio Proxy")
78
 
79
- # The proxy can now be used with any transport
80
- # No special handling needed - it works like any FastMCP server
 
 
81
  ```
82
 
83
- ### In-Memory Proxies
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
- You can also proxy an in-memory `FastMCP` instance, which is useful for adjusting the configuration or behavior of a server you don't completely control.
86
 
87
  ```python
88
  from fastmcp import FastMCP
 
89
 
90
- # Original server
91
- original_server = FastMCP(name="Original")
 
 
 
92
 
93
- @original_server.tool
94
- def tool_a() -> str:
95
- return "A"
 
96
 
97
- # Create a proxy of the original server directly
98
- proxy = FastMCP.as_proxy(
99
- original_server,
100
- name="Proxy Server"
 
 
 
101
  )
102
 
103
- # proxy is now a regular FastMCP server that forwards
104
- # requests to original_server
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  ```
106
 
107
- ### Configuration-Based Proxies
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
  <VersionBadge version="2.4.0" />
110
 
@@ -123,7 +201,7 @@ config = {
123
  }
124
  }
125
 
126
- # Create a proxy to the configured server
127
  proxy = FastMCP.as_proxy(config, name="Config-Based Proxy")
128
 
129
  # Run the proxy with stdio transport for local access
@@ -135,11 +213,11 @@ if __name__ == "__main__":
135
  The MCPConfig format follows an emerging standard for MCP server configuration and may evolve as the specification matures. While FastMCP aims to maintain compatibility with future versions, be aware that field names or structure might change.
136
  </Note>
137
 
138
- You can also use MCPConfig to create a proxy to multiple servers. When multiple servers are specified, they are automatically mounted with their config names as prefixes, providing a unified interface to all servers:
139
 
140
- ```python
141
- from fastmcp import FastMCP
142
 
 
143
  # Multi-server configuration
144
  config = {
145
  "mcpServers": {
@@ -154,7 +232,7 @@ config = {
154
  }
155
  }
156
 
157
- # Create a proxy to multiple servers
158
  composite_proxy = FastMCP.as_proxy(config, name="Composite Proxy")
159
 
160
  # Tools and resources are accessible with prefixes:
@@ -162,14 +240,72 @@ composite_proxy = FastMCP.as_proxy(config, name="Composite Proxy")
162
  # - weather://weather/icons/sunny, calendar://calendar/events/today
163
  ```
164
 
165
- ## Forwarding Interactions
 
 
166
 
167
- `ProxyClient` is a subclass of `Client` that implements a set of default handlers to forward advanced interactions between the backend server and the client connected to the proxy. These handlers receive requests or notifications from the backend server and forward them to the client through the related request context, relaying the response back to the backend server if needed. This setup enables the proxy to support advanced MCP features, including roots, sampling, elicitation, logging, and progress.
168
 
169
- To prevent the forwarding of any interaction, pass `None` to the corresponding handler when creating the `ProxyClient`. Typically, you should use `ProxyClient` to establish a proxy unless there is a specific reason not to. If the `transport` parameter is not an instance of `Client`, `FastMCP.as_proxy()` will automatically instantiate a `ProxyClient`.
 
 
 
 
 
 
 
 
 
 
170
 
171
  ## `FastMCPProxy` Class
172
 
173
- Internally, `FastMCP.as_proxy()` uses the `FastMCPProxy` class. You generally don't need to interact with this class directly, but it's available if needed.
 
 
174
 
175
- Using the class directly might be necessary for advanced scenarios, like subclassing `FastMCPProxy` to add custom logic before or after forwarding requests.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  FastMCP provides a powerful proxying capability that allows one FastMCP server instance to act as a frontend for another MCP server (which could be remote, running on a different transport, or even another FastMCP instance). This is achieved using the `FastMCP.as_proxy()` class method.
12
 
 
 
13
  ## What is Proxying?
14
 
15
  Proxying means setting up a FastMCP server that doesn't implement its own tools or resources directly. Instead, when it receives a request (like `tools/call` or `resources/read`), it forwards that request to a *backend* MCP server, receives the response, and then relays that response back to the original client.
16
 
 
17
  ```mermaid
18
  sequenceDiagram
19
  participant ClientApp as Your Client (e.g., Claude Desktop)
 
27
  Note over ClientApp, FastMCPProxy: Proxy relays the response
28
  FastMCPProxy-->>ClientApp: MCP Response (e.g. stdio)
29
  ```
 
30
 
31
+ ### Key Benefits
32
+
33
+ <VersionBadge version="2.10.3" />
34
+
35
+ - **Session Isolation**: Each request gets its own isolated session, ensuring safe concurrent operations
36
+ - **Transport Bridging**: Expose servers running on one transport via a different transport
37
+ - **Advanced MCP Features**: Automatic forwarding of sampling, elicitation, logging, and progress
38
+ - **Security**: Acts as a controlled gateway to backend servers
39
+ - **Simplicity**: Single endpoint even if backend location or transport changes
40
+
41
+ ## Quick Start
42
 
43
+ <VersionBadge version="2.10.3" />
44
 
45
+ The recommended way to create a proxy is using `ProxyClient`, which provides full MCP feature support with automatic session isolation:
46
 
47
  ```python
48
  from fastmcp import FastMCP
49
+ from fastmcp.server.proxy import ProxyClient
50
 
51
+ # Create a proxy with full MCP feature support
52
+ proxy = FastMCP.as_proxy(
53
+ ProxyClient("backend_server.py"),
54
+ name="MyProxy"
55
  )
56
 
57
+ # Run the proxy (e.g., via stdio for Claude Desktop)
58
+ if __name__ == "__main__":
59
+ proxy.run()
60
  ```
61
 
62
+ This single setup gives you:
63
+ - Safe concurrent request handling
64
+ - Automatic forwarding of advanced MCP features (sampling, elicitation, etc.)
65
+ - Session isolation to prevent context mixing
66
+ - Full compatibility with all MCP clients
67
 
68
+ ## Session Isolation & Concurrency
 
 
 
69
 
70
+ <VersionBadge version="2.10.3" />
71
+
72
+ FastMCP proxies provide session isolation to ensure safe concurrent operations. The session strategy depends on how the proxy is configured:
73
 
74
+ ### Fresh Sessions
75
 
76
+ When you pass a disconnected client (which is the normal case), each request gets its own isolated backend session:
77
 
78
  ```python
79
+ from fastmcp.server.proxy import ProxyClient
80
 
81
+ # Each request creates a fresh backend session (recommended)
82
+ proxy = FastMCP.as_proxy(ProxyClient("backend_server.py"))
83
 
84
+ # Multiple clients can use this proxy simultaneously without interference:
85
+ # - Client A calls a tool -> gets isolated backend session
86
+ # - Client B calls a tool -> gets different isolated backend session
87
+ # - No context mixing between requests
88
  ```
89
 
90
+ ### Session Reuse with Connected Clients
91
+
92
+ When you pass an already-connected client, the proxy will reuse that session for all requests:
93
+
94
+ ```python
95
+ from fastmcp import Client
96
+
97
+ # Create and connect a client
98
+ async with Client("backend_server.py") as connected_client:
99
+ # This proxy will reuse the connected session for all requests
100
+ proxy = FastMCP.as_proxy(connected_client)
101
+
102
+ # ⚠️ Warning: All requests share the same backend session
103
+ # This may cause context mixing in concurrent scenarios
104
+ ```
105
+
106
+ **Important**: Using shared sessions with concurrent requests from multiple clients may lead to context mixing and race conditions. This approach should only be used in single-threaded scenarios or when you have explicit synchronization.
107
+
108
+ ## Transport Bridging
109
 
110
+ A common use case is bridging transports - exposing a server running on one transport via a different transport. For example, making a remote SSE server available locally via stdio:
111
 
112
  ```python
113
  from fastmcp import FastMCP
114
+ from fastmcp.server.proxy import ProxyClient
115
 
116
+ # Bridge remote SSE server to local stdio
117
+ remote_proxy = FastMCP.as_proxy(
118
+ ProxyClient("http://example.com/mcp/sse"),
119
+ name="Remote-to-Local Bridge"
120
+ )
121
 
122
+ # Run locally via stdio for Claude Desktop
123
+ if __name__ == "__main__":
124
+ remote_proxy.run() # Defaults to stdio transport
125
+ ```
126
 
127
+ Or expose a local server via HTTP for remote access:
128
+
129
+ ```python
130
+ # Bridge local server to HTTP
131
+ local_proxy = FastMCP.as_proxy(
132
+ ProxyClient("local_server.py"),
133
+ name="Local-to-HTTP Bridge"
134
  )
135
 
136
+ # Run via HTTP for remote clients
137
+ if __name__ == "__main__":
138
+ local_proxy.run(transport="http", host="0.0.0.0", port=8080)
139
+ ```
140
+
141
+
142
+ ## Advanced MCP Features
143
+
144
+ <VersionBadge version="2.10.3" />
145
+
146
+ `ProxyClient` automatically forwards advanced MCP protocol features between the backend server and clients connected to the proxy, ensuring full MCP compatibility.
147
+
148
+ ### Supported Features
149
+
150
+ - **Roots**: Forwards filesystem root access requests to the client
151
+ - **Sampling**: Forwards LLM completion requests from backend to client
152
+ - **Elicitation**: Forwards user input requests to the client
153
+ - **Logging**: Forwards log messages from backend through to client
154
+ - **Progress**: Forwards progress notifications during long operations
155
+
156
+ ```python
157
+ from fastmcp.server.proxy import ProxyClient
158
+
159
+ # ProxyClient automatically handles all these features
160
+ backend = ProxyClient("advanced_backend.py")
161
+ proxy = FastMCP.as_proxy(backend)
162
+
163
+ # When the backend server:
164
+ # - Requests LLM sampling -> forwarded to your client
165
+ # - Logs messages -> appear in your client
166
+ # - Reports progress -> shown in your client
167
+ # - Needs user input -> prompts your client
168
  ```
169
 
170
+ ### Customizing Feature Support
171
+
172
+ You can selectively disable forwarding by passing `None` for specific handlers:
173
+
174
+ ```python
175
+ # Disable sampling but keep other features
176
+ backend = ProxyClient(
177
+ "backend_server.py",
178
+ sampling_handler=None, # Disable LLM sampling forwarding
179
+ log_handler=None # Disable log forwarding
180
+ )
181
+ ```
182
+
183
+ When you use a transport string directly with `FastMCP.as_proxy()`, it automatically creates a `ProxyClient` internally to ensure full feature support.
184
+
185
+ ## Configuration-Based Proxies
186
 
187
  <VersionBadge version="2.4.0" />
188
 
 
201
  }
202
  }
203
 
204
+ # Create a proxy to the configured server (auto-creates ProxyClient)
205
  proxy = FastMCP.as_proxy(config, name="Config-Based Proxy")
206
 
207
  # Run the proxy with stdio transport for local access
 
213
  The MCPConfig format follows an emerging standard for MCP server configuration and may evolve as the specification matures. While FastMCP aims to maintain compatibility with future versions, be aware that field names or structure might change.
214
  </Note>
215
 
216
+ ### Multi-Server Configurations
217
 
218
+ You can create a proxy to multiple servers by specifying multiple entries in the config. They are automatically mounted with their config names as prefixes:
 
219
 
220
+ ```python
221
  # Multi-server configuration
222
  config = {
223
  "mcpServers": {
 
232
  }
233
  }
234
 
235
+ # Create a unified proxy to multiple servers
236
  composite_proxy = FastMCP.as_proxy(config, name="Composite Proxy")
237
 
238
  # Tools and resources are accessible with prefixes:
 
240
  # - weather://weather/icons/sunny, calendar://calendar/events/today
241
  ```
242
 
243
+ ## Alternative Approaches
244
+
245
+ The examples above show the recommended approach using `ProxyClient` or transport strings. For advanced use cases, you can also work directly with the underlying classes.
246
 
247
+ ### Using Regular Client
248
 
249
+ You can pass a regular `Client` instance to `as_proxy()`. The proxy will automatically create an appropriate session strategy:
250
+
251
+ ```python
252
+ from fastmcp import FastMCP, Client
253
+
254
+ # Using regular Client (session strategy auto-detected)
255
+ client = Client("backend_server.py")
256
+ proxy = FastMCP.as_proxy(client)
257
+ ```
258
+
259
+ This approach provides session isolation but doesn't include advanced MCP feature forwarding (sampling, elicitation, etc.) unless you configure handlers manually.
260
 
261
  ## `FastMCPProxy` Class
262
 
263
+ Internally, `FastMCP.as_proxy()` uses the `FastMCPProxy` class. You generally don't need to interact with this class directly, but it's available if needed for advanced scenarios.
264
+
265
+ ### Direct Usage
266
 
267
+ ```python
268
+ from fastmcp.server.proxy import FastMCPProxy, ProxyClient
269
+
270
+ # Provide a client factory for explicit session control
271
+ def create_client():
272
+ return ProxyClient("backend_server.py")
273
+
274
+ proxy = FastMCPProxy(client_factory=create_client)
275
+ ```
276
+
277
+ ### Parameters
278
+
279
+ - **`client`**: **[DEPRECATED]** A `Client` instance. Use `client_factory` instead for explicit session management.
280
+ - **`client_factory`**: A callable that returns a `Client` instance when called. This gives you full control over session creation and reuse strategies.
281
+
282
+ ### Explicit Session Management
283
+
284
+ `FastMCPProxy` requires explicit session management - no automatic detection is performed. You must choose your session strategy:
285
+
286
+ ```python
287
+ # Share session across all requests (be careful with concurrency)
288
+ shared_client = ProxyClient("backend_server.py")
289
+ def shared_session_factory():
290
+ return shared_client
291
+
292
+ proxy = FastMCPProxy(client_factory=shared_session_factory)
293
+
294
+ # Create fresh sessions per request (recommended)
295
+ def fresh_session_factory():
296
+ return ProxyClient("backend_server.py")
297
+
298
+ proxy = FastMCPProxy(client_factory=fresh_session_factory)
299
+ ```
300
+
301
+ For automatic session strategy selection, use the convenience method `FastMCP.as_proxy()` instead.
302
+
303
+ ```python
304
+ # Custom factory with specific configuration
305
+ def custom_client_factory():
306
+ client = ProxyClient("backend_server.py")
307
+ # Add any custom configuration here
308
+ return client
309
+
310
+ proxy = FastMCPProxy(client_factory=custom_client_factory)
311
+ ```
docs/servers/server.mdx CHANGED
@@ -220,6 +220,8 @@ main.mount(sub, prefix="sub")
220
 
221
  FastMCP can act as a proxy for any MCP server (local or remote) using `FastMCP.as_proxy`, letting you bridge transports or add a frontend to existing servers. For example, you can expose a remote SSE server locally via stdio, or vice versa.
222
 
 
 
223
  See the [Proxying Servers](/servers/proxy) guide for details and advanced usage.
224
 
225
  ```python
 
220
 
221
  FastMCP can act as a proxy for any MCP server (local or remote) using `FastMCP.as_proxy`, letting you bridge transports or add a frontend to existing servers. For example, you can expose a remote SSE server locally via stdio, or vice versa.
222
 
223
+ Proxies automatically handle concurrent operations safely by creating fresh sessions for each request when using disconnected clients.
224
+
225
  See the [Proxying Servers](/servers/proxy) guide for details and advanced usage.
226
 
227
  ```python
src/fastmcp/client/client.py CHANGED
@@ -1,11 +1,12 @@
1
  from __future__ import annotations
2
 
3
  import asyncio
 
4
  import datetime
5
  from contextlib import AsyncExitStack, asynccontextmanager
6
- from dataclasses import dataclass
7
  from pathlib import Path
8
- from typing import Any, Generic, Literal, cast, overload
9
 
10
  import anyio
11
  import httpx
@@ -39,6 +40,7 @@ from fastmcp.utilities.logging import get_logger
39
  from fastmcp.utilities.types import get_cached_typeadapter
40
 
41
  from .transports import (
 
42
  ClientTransportT,
43
  FastMCP1Server,
44
  FastMCPTransport,
@@ -65,6 +67,25 @@ __all__ = [
65
 
66
  logger = get_logger(__name__)
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  class Client(Generic[ClientTransportT]):
70
  """
@@ -89,8 +110,8 @@ class Client(Generic[ClientTransportT]):
89
  between tasks, and ensures all session state changes happen within a lock.
90
  Events are only created when needed, never reset outside locks.
91
 
92
- See: https://github.com/jlowin/fastmcp/issues/1051
93
- https://github.com/jlowin/fastmcp/pull/1054
94
 
95
  Args:
96
  transport:
@@ -127,56 +148,65 @@ class Client(Generic[ClientTransportT]):
127
  """
128
 
129
  @overload
130
- def __new__(
131
- cls,
132
- transport: ClientTransportT,
133
- **kwargs: Any,
134
- ) -> Client[ClientTransportT]: ...
135
 
136
  @overload
137
- def __new__(
138
- cls, transport: AnyUrl, **kwargs
139
- ) -> Client[SSETransport | StreamableHttpTransport]: ...
 
 
 
140
 
141
  @overload
142
- def __new__(
143
- cls, transport: FastMCP | FastMCP1Server, **kwargs
144
- ) -> Client[FastMCPTransport]: ...
 
 
 
145
 
146
  @overload
147
- def __new__(
148
- cls, transport: Path, **kwargs
149
- ) -> Client[PythonStdioTransport | NodeStdioTransport]: ...
 
 
 
150
 
151
  @overload
152
- def __new__(
153
- cls, transport: MCPConfig | dict[str, Any], **kwargs
154
- ) -> Client[MCPConfigTransport]: ...
 
 
 
155
 
156
  @overload
157
- def __new__(
158
- cls, transport: str, **kwargs
159
- ) -> Client[
160
- PythonStdioTransport
161
- | NodeStdioTransport
162
- | SSETransport
163
- | StreamableHttpTransport
164
- ]: ...
165
-
166
- def __new__(cls, transport, **kwargs) -> Client:
167
- instance = super().__new__(cls)
168
- return instance
169
 
170
  def __init__(
171
  self,
172
- transport: ClientTransportT
173
- | FastMCP
174
- | AnyUrl
175
- | Path
176
- | MCPConfig
177
- | dict[str, Any]
178
- | str,
179
- # Common args
 
 
180
  roots: RootsList | RootsHandler | None = None,
181
  sampling_handler: SamplingHandler | None = None,
182
  elicitation_handler: ElicitationHandler | None = None,
@@ -187,11 +217,10 @@ class Client(Generic[ClientTransportT]):
187
  init_timeout: datetime.timedelta | float | int | None = None,
188
  client_info: mcp.types.Implementation | None = None,
189
  auth: httpx.Auth | Literal["oauth"] | str | None = None,
190
- ):
191
  self.transport = cast(ClientTransportT, infer_transport(transport))
192
  if auth is not None:
193
  self.transport._set_auth(auth)
194
- self._initialize_result: mcp.types.InitializeResult | None = None
195
 
196
  if log_handler is None:
197
  log_handler = default_log_handler
@@ -238,33 +267,26 @@ class Client(Generic[ClientTransportT]):
238
  )
239
 
240
  # Session context management - see class docstring for detailed explanation
241
- self._session: ClientSession | None = None # Active MCP session
242
- self._nesting_counter: int = 0 # Reference count for active context managers
243
- self._context_lock = anyio.Lock() # Protects all session state changes
244
- self._session_task: asyncio.Task | None = (
245
- None # Background session manager task
246
- )
247
- self._ready_event = anyio.Event() # Signals when session is ready for use
248
- self._stop_event = anyio.Event() # Signals when session should stop
249
 
250
  @property
251
  def session(self) -> ClientSession:
252
  """Get the current active session. Raises RuntimeError if not connected."""
253
- if self._session is None:
254
  raise RuntimeError(
255
  "Client is not connected. Use the 'async with client:' context manager first."
256
  )
257
 
258
- return self._session
259
 
260
  @property
261
  def initialize_result(self) -> mcp.types.InitializeResult:
262
  """Get the result of the initialization request."""
263
- if self._initialize_result is None:
264
  raise RuntimeError(
265
  "Client is not connected. Use the 'async with client:' context manager first."
266
  )
267
- return self._initialize_result
268
 
269
  def set_roots(self, roots: RootsList | RootsHandler) -> None:
270
  """Set the roots for the client. This does not automatically call `send_roots_list_changed`."""
@@ -286,7 +308,33 @@ class Client(Generic[ClientTransportT]):
286
 
287
  def is_connected(self) -> bool:
288
  """Check if the client is currently connected."""
289
- return self._session is not None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
 
291
  @asynccontextmanager
292
  async def _context_manager(self):
@@ -294,19 +342,21 @@ class Client(Generic[ClientTransportT]):
294
  async with self.transport.connect_session(
295
  **self._session_kwargs
296
  ) as session:
297
- self._session = session
298
  # Initialize the session
299
  try:
300
  with anyio.fail_after(self._init_timeout):
301
- self._initialize_result = await self._session.initialize()
 
 
302
  yield
303
  except anyio.ClosedResourceError:
304
  raise RuntimeError("Server session was closed unexpectedly")
305
  except TimeoutError:
306
  raise RuntimeError("Failed to initialize server session")
307
  finally:
308
- self._session = None
309
- self._initialize_result = None
310
 
311
  async def __aenter__(self):
312
  return await self._connect()
@@ -328,20 +378,25 @@ class Client(Generic[ClientTransportT]):
328
  tasks wait on events that get replaced by other tasks.
329
  """
330
  # ensure only one session is running at a time to avoid race conditions
331
- async with self._context_lock:
332
- need_to_start = self._session_task is None or self._session_task.done()
 
 
 
333
  if need_to_start:
334
- if self._nesting_counter != 0:
335
  raise RuntimeError(
336
- f"Internal error: nesting counter should be 0 when starting new session, got {self._nesting_counter}"
337
  )
338
- self._stop_event = anyio.Event()
339
- self._ready_event = anyio.Event()
340
- self._session_task = asyncio.create_task(self._session_runner())
341
- await self._ready_event.wait()
342
-
343
- if self._session_task.done():
344
- exception = self._session_task.exception()
 
 
345
  if exception is None:
346
  raise RuntimeError(
347
  "Session task completed without exception but connection failed"
@@ -352,7 +407,7 @@ class Client(Generic[ClientTransportT]):
352
  f"Client failed to connect: {exception}"
353
  ) from exception
354
 
355
- self._nesting_counter += 1
356
  return self
357
 
358
  async def _disconnect(self, force: bool = False):
@@ -370,26 +425,28 @@ class Client(Generic[ClientTransportT]):
370
  Event recreation now happens only in _connect() when actually needed.
371
  """
372
  # ensure only one session is running at a time to avoid race conditions
373
- async with self._context_lock:
374
  # if we are forcing a disconnect, reset the nesting counter
375
  if force:
376
- self._nesting_counter = 0
377
 
378
  # otherwise decrement to check if we are done nesting
379
  else:
380
- self._nesting_counter = max(0, self._nesting_counter - 1)
 
 
381
 
382
  # if we are still nested, return
383
- if self._nesting_counter > 0:
384
  return
385
 
386
  # stop the active seesion
387
- if self._session_task is None:
388
  return
389
- self._stop_event.set()
390
  # wait for session to finish to ensure state has been reset
391
- await self._session_task
392
- self._session_task = None
393
 
394
  async def _session_runner(self):
395
  """
@@ -409,12 +466,12 @@ class Client(Generic[ClientTransportT]):
409
  async with AsyncExitStack() as stack:
410
  await stack.enter_async_context(self._context_manager())
411
  # Session/context is now ready
412
- self._ready_event.set()
413
  # Wait until disconnect/stop is requested
414
- await self._stop_event.wait()
415
  finally:
416
  # Ensure ready event is set even if context manager entry fails
417
- self._ready_event.set()
418
 
419
  async def close(self):
420
  await self._disconnect(force=True)
 
1
  from __future__ import annotations
2
 
3
  import asyncio
4
+ import copy
5
  import datetime
6
  from contextlib import AsyncExitStack, asynccontextmanager
7
+ from dataclasses import dataclass, field
8
  from pathlib import Path
9
+ from typing import Any, Generic, Literal, TypeVar, cast, overload
10
 
11
  import anyio
12
  import httpx
 
40
  from fastmcp.utilities.types import get_cached_typeadapter
41
 
42
  from .transports import (
43
+ ClientTransport,
44
  ClientTransportT,
45
  FastMCP1Server,
46
  FastMCPTransport,
 
67
 
68
  logger = get_logger(__name__)
69
 
70
+ T = TypeVar("T", bound="ClientTransport")
71
+
72
+
73
+ @dataclass
74
+ class ClientSessionState:
75
+ """Holds all session-related state for a Client instance.
76
+
77
+ This allows clean separation of configuration (which is copied) from
78
+ session state (which should be fresh for each new client instance).
79
+ """
80
+
81
+ session: ClientSession | None = None
82
+ nesting_counter: int = 0
83
+ lock: anyio.Lock = field(default_factory=anyio.Lock)
84
+ session_task: asyncio.Task | None = None
85
+ ready_event: anyio.Event = field(default_factory=anyio.Event)
86
+ stop_event: anyio.Event = field(default_factory=anyio.Event)
87
+ initialize_result: mcp.types.InitializeResult | None = None
88
+
89
 
90
  class Client(Generic[ClientTransportT]):
91
  """
 
110
  between tasks, and ensures all session state changes happen within a lock.
111
  Events are only created when needed, never reset outside locks.
112
 
113
+ This design prevents race conditions where tasks wait on events that get
114
+ replaced by other tasks, ensuring reliable coordination in concurrent scenarios.
115
 
116
  Args:
117
  transport:
 
148
  """
149
 
150
  @overload
151
+ def __init__(self: Client[T], transport: T, *args, **kwargs) -> None: ...
 
 
 
 
152
 
153
  @overload
154
+ def __init__(
155
+ self: Client[SSETransport | StreamableHttpTransport],
156
+ transport: AnyUrl,
157
+ *args,
158
+ **kwargs,
159
+ ) -> None: ...
160
 
161
  @overload
162
+ def __init__(
163
+ self: Client[FastMCPTransport],
164
+ transport: FastMCP | FastMCP1Server,
165
+ *args,
166
+ **kwargs,
167
+ ) -> None: ...
168
 
169
  @overload
170
+ def __init__(
171
+ self: Client[PythonStdioTransport | NodeStdioTransport],
172
+ transport: Path,
173
+ *args,
174
+ **kwargs,
175
+ ) -> None: ...
176
 
177
  @overload
178
+ def __init__(
179
+ self: Client[MCPConfigTransport],
180
+ transport: MCPConfig | dict[str, Any],
181
+ *args,
182
+ **kwargs,
183
+ ) -> None: ...
184
 
185
  @overload
186
+ def __init__(
187
+ self: Client[
188
+ PythonStdioTransport
189
+ | NodeStdioTransport
190
+ | SSETransport
191
+ | StreamableHttpTransport
192
+ ],
193
+ transport: str,
194
+ *args,
195
+ **kwargs,
196
+ ) -> None: ...
 
197
 
198
  def __init__(
199
  self,
200
+ transport: (
201
+ ClientTransportT
202
+ | FastMCP
203
+ | FastMCP1Server
204
+ | AnyUrl
205
+ | Path
206
+ | MCPConfig
207
+ | dict[str, Any]
208
+ | str
209
+ ),
210
  roots: RootsList | RootsHandler | None = None,
211
  sampling_handler: SamplingHandler | None = None,
212
  elicitation_handler: ElicitationHandler | None = None,
 
217
  init_timeout: datetime.timedelta | float | int | None = None,
218
  client_info: mcp.types.Implementation | None = None,
219
  auth: httpx.Auth | Literal["oauth"] | str | None = None,
220
+ ) -> None:
221
  self.transport = cast(ClientTransportT, infer_transport(transport))
222
  if auth is not None:
223
  self.transport._set_auth(auth)
 
224
 
225
  if log_handler is None:
226
  log_handler = default_log_handler
 
267
  )
268
 
269
  # Session context management - see class docstring for detailed explanation
270
+ self._session_state = ClientSessionState()
 
 
 
 
 
 
 
271
 
272
  @property
273
  def session(self) -> ClientSession:
274
  """Get the current active session. Raises RuntimeError if not connected."""
275
+ if self._session_state.session is None:
276
  raise RuntimeError(
277
  "Client is not connected. Use the 'async with client:' context manager first."
278
  )
279
 
280
+ return self._session_state.session
281
 
282
  @property
283
  def initialize_result(self) -> mcp.types.InitializeResult:
284
  """Get the result of the initialization request."""
285
+ if self._session_state.initialize_result is None:
286
  raise RuntimeError(
287
  "Client is not connected. Use the 'async with client:' context manager first."
288
  )
289
+ return self._session_state.initialize_result
290
 
291
  def set_roots(self, roots: RootsList | RootsHandler) -> None:
292
  """Set the roots for the client. This does not automatically call `send_roots_list_changed`."""
 
308
 
309
  def is_connected(self) -> bool:
310
  """Check if the client is currently connected."""
311
+ return self._session_state.session is not None
312
+
313
+ def new(self) -> Client[ClientTransportT]:
314
+ """Create a new client instance with the same configuration but fresh session state.
315
+
316
+ This creates a new client with the same transport, handlers, and configuration,
317
+ but with no active session. Useful for creating independent sessions that don't
318
+ share state with the original client.
319
+
320
+ Returns:
321
+ A new Client instance with the same configuration but disconnected state.
322
+
323
+ Example:
324
+ ```python
325
+ # Create a fresh client for each concurrent operation
326
+ fresh_client = client.new()
327
+ async with fresh_client:
328
+ await fresh_client.call_tool("some_tool", {})
329
+ ```
330
+ """
331
+
332
+ new_client = copy.copy(self)
333
+
334
+ # Reset session state to fresh state
335
+ new_client._session_state = ClientSessionState()
336
+
337
+ return new_client
338
 
339
  @asynccontextmanager
340
  async def _context_manager(self):
 
342
  async with self.transport.connect_session(
343
  **self._session_kwargs
344
  ) as session:
345
+ self._session_state.session = session
346
  # Initialize the session
347
  try:
348
  with anyio.fail_after(self._init_timeout):
349
+ self._session_state.initialize_result = (
350
+ await self._session_state.session.initialize()
351
+ )
352
  yield
353
  except anyio.ClosedResourceError:
354
  raise RuntimeError("Server session was closed unexpectedly")
355
  except TimeoutError:
356
  raise RuntimeError("Failed to initialize server session")
357
  finally:
358
+ self._session_state.session = None
359
+ self._session_state.initialize_result = None
360
 
361
  async def __aenter__(self):
362
  return await self._connect()
 
378
  tasks wait on events that get replaced by other tasks.
379
  """
380
  # ensure only one session is running at a time to avoid race conditions
381
+ async with self._session_state.lock:
382
+ need_to_start = (
383
+ self._session_state.session_task is None
384
+ or self._session_state.session_task.done()
385
+ )
386
  if need_to_start:
387
+ if self._session_state.nesting_counter != 0:
388
  raise RuntimeError(
389
+ f"Internal error: nesting counter should be 0 when starting new session, got {self._session_state.nesting_counter}"
390
  )
391
+ self._session_state.stop_event = anyio.Event()
392
+ self._session_state.ready_event = anyio.Event()
393
+ self._session_state.session_task = asyncio.create_task(
394
+ self._session_runner()
395
+ )
396
+ await self._session_state.ready_event.wait()
397
+
398
+ if self._session_state.session_task.done():
399
+ exception = self._session_state.session_task.exception()
400
  if exception is None:
401
  raise RuntimeError(
402
  "Session task completed without exception but connection failed"
 
407
  f"Client failed to connect: {exception}"
408
  ) from exception
409
 
410
+ self._session_state.nesting_counter += 1
411
  return self
412
 
413
  async def _disconnect(self, force: bool = False):
 
425
  Event recreation now happens only in _connect() when actually needed.
426
  """
427
  # ensure only one session is running at a time to avoid race conditions
428
+ async with self._session_state.lock:
429
  # if we are forcing a disconnect, reset the nesting counter
430
  if force:
431
+ self._session_state.nesting_counter = 0
432
 
433
  # otherwise decrement to check if we are done nesting
434
  else:
435
+ self._session_state.nesting_counter = max(
436
+ 0, self._session_state.nesting_counter - 1
437
+ )
438
 
439
  # if we are still nested, return
440
+ if self._session_state.nesting_counter > 0:
441
  return
442
 
443
  # stop the active seesion
444
+ if self._session_state.session_task is None:
445
  return
446
+ self._session_state.stop_event.set()
447
  # wait for session to finish to ensure state has been reset
448
+ await self._session_state.session_task
449
+ self._session_state.session_task = None
450
 
451
  async def _session_runner(self):
452
  """
 
466
  async with AsyncExitStack() as stack:
467
  await stack.enter_async_context(self._context_manager())
468
  # Session/context is now ready
469
+ self._session_state.ready_event.set()
470
  # Wait until disconnect/stop is requested
471
+ await self._session_state.stop_event.wait()
472
  finally:
473
  # Ensure ready event is set even if context manager entry fails
474
+ self._session_state.ready_event.set()
475
 
476
  async def close(self):
477
  await self._disconnect(force=True)
src/fastmcp/server/proxy.py CHANGED
@@ -1,5 +1,7 @@
1
  from __future__ import annotations
2
 
 
 
3
  from pathlib import Path
4
  from typing import TYPE_CHECKING, Any, cast
5
  from urllib.parse import quote
@@ -16,7 +18,8 @@ from mcp.types import (
16
  )
17
  from pydantic.networks import AnyUrl
18
 
19
- from fastmcp.client import Client
 
20
  from fastmcp.client.elicitation import ElicitResult
21
  from fastmcp.client.logging import LogMessage
22
  from fastmcp.client.roots import RootsList
@@ -44,9 +47,9 @@ logger = get_logger(__name__)
44
  class ProxyToolManager(ToolManager):
45
  """A ToolManager that sources its tools from a remote client in addition to local and mounted tools."""
46
 
47
- def __init__(self, client: Client, **kwargs):
48
  super().__init__(**kwargs)
49
- self.client = client
50
 
51
  async def get_tools(self) -> dict[str, Tool]:
52
  """Gets the unfiltered tool inventory including local, mounted, and proxy tools."""
@@ -55,13 +58,12 @@ class ProxyToolManager(ToolManager):
55
 
56
  # Then add proxy tools, but don't overwrite existing ones
57
  try:
58
- async with self.client:
59
- client_tools = await self.client.list_tools()
 
60
  for tool in client_tools:
61
  if tool.name not in all_tools:
62
- all_tools[tool.name] = ProxyTool.from_mcp_tool(
63
- self.client, tool
64
- )
65
  except McpError as e:
66
  if e.error.code == METHOD_NOT_FOUND:
67
  pass # No tools available from proxy
@@ -82,8 +84,9 @@ class ProxyToolManager(ToolManager):
82
  return await super().call_tool(key, arguments)
83
  except NotFoundError:
84
  # If not found locally, try proxy
85
- async with self.client:
86
- result = await self.client.call_tool(key, arguments)
 
87
  return ToolResult(
88
  content=result.content,
89
  structured_content=result.structured_content,
@@ -93,9 +96,9 @@ class ProxyToolManager(ToolManager):
93
  class ProxyResourceManager(ResourceManager):
94
  """A ResourceManager that sources its resources from a remote client in addition to local and mounted resources."""
95
 
96
- def __init__(self, client: Client, **kwargs):
97
  super().__init__(**kwargs)
98
- self.client = client
99
 
100
  async def get_resources(self) -> dict[str, Resource]:
101
  """Gets the unfiltered resource inventory including local, mounted, and proxy resources."""
@@ -104,12 +107,13 @@ class ProxyResourceManager(ResourceManager):
104
 
105
  # Then add proxy resources, but don't overwrite existing ones
106
  try:
107
- async with self.client:
108
- client_resources = await self.client.list_resources()
 
109
  for resource in client_resources:
110
  if str(resource.uri) not in all_resources:
111
  all_resources[str(resource.uri)] = (
112
- ProxyResource.from_mcp_resource(self.client, resource)
113
  )
114
  except McpError as e:
115
  if e.error.code == METHOD_NOT_FOUND:
@@ -126,12 +130,13 @@ class ProxyResourceManager(ResourceManager):
126
 
127
  # Then add proxy templates, but don't overwrite existing ones
128
  try:
129
- async with self.client:
130
- client_templates = await self.client.list_resource_templates()
 
131
  for template in client_templates:
132
  if template.uriTemplate not in all_templates:
133
  all_templates[template.uriTemplate] = (
134
- ProxyTemplate.from_mcp_template(self.client, template)
135
  )
136
  except McpError as e:
137
  if e.error.code == METHOD_NOT_FOUND:
@@ -158,8 +163,9 @@ class ProxyResourceManager(ResourceManager):
158
  return await super().read_resource(uri)
159
  except NotFoundError:
160
  # If not found locally, try proxy
161
- async with self.client:
162
- result = await self.client.read_resource(uri)
 
163
  if isinstance(result[0], TextResourceContents):
164
  return result[0].text
165
  elif isinstance(result[0], BlobResourceContents):
@@ -171,9 +177,9 @@ class ProxyResourceManager(ResourceManager):
171
  class ProxyPromptManager(PromptManager):
172
  """A PromptManager that sources its prompts from a remote client in addition to local and mounted prompts."""
173
 
174
- def __init__(self, client: Client, **kwargs):
175
  super().__init__(**kwargs)
176
- self.client = client
177
 
178
  async def get_prompts(self) -> dict[str, Prompt]:
179
  """Gets the unfiltered prompt inventory including local, mounted, and proxy prompts."""
@@ -182,12 +188,13 @@ class ProxyPromptManager(PromptManager):
182
 
183
  # Then add proxy prompts, but don't overwrite existing ones
184
  try:
185
- async with self.client:
186
- client_prompts = await self.client.list_prompts()
 
187
  for prompt in client_prompts:
188
  if prompt.name not in all_prompts:
189
  all_prompts[prompt.name] = ProxyPrompt.from_mcp_prompt(
190
- self.client, prompt
191
  )
192
  except McpError as e:
193
  if e.error.code == METHOD_NOT_FOUND:
@@ -213,8 +220,9 @@ class ProxyPromptManager(PromptManager):
213
  return await super().render_prompt(name, arguments)
214
  except NotFoundError:
215
  # If not found locally, try proxy
216
- async with self.client:
217
- result = await self.client.get_prompt(name, arguments)
 
218
  return result
219
 
220
 
@@ -245,7 +253,6 @@ class ProxyTool(Tool):
245
  context: Context | None = None,
246
  ) -> ToolResult:
247
  """Executes the tool by making a call through the client."""
248
- # This is where the remote execution logic lives.
249
  async with self._client:
250
  result = await self._client.call_tool_mcp(
251
  name=self.name,
@@ -267,14 +274,22 @@ class ProxyResource(Resource):
267
  _client: Client
268
  _value: str | bytes | None = None
269
 
270
- def __init__(self, client: Client, *, _value: str | bytes | None = None, **kwargs):
 
 
 
 
 
 
271
  super().__init__(**kwargs)
272
  self._client = client
273
  self._value = _value
274
 
275
  @classmethod
276
  def from_mcp_resource(
277
- cls, client: Client, mcp_resource: mcp.types.Resource
 
 
278
  ) -> ProxyResource:
279
  """Factory method to create a ProxyResource from a raw MCP resource schema."""
280
  return cls(
@@ -397,24 +412,63 @@ class ProxyPrompt(Prompt):
397
  class FastMCPProxy(FastMCP):
398
  """
399
  A FastMCP server that acts as a proxy to a remote MCP-compliant server.
400
- It uses specialized managers that fulfill requests via an HTTP client.
401
  """
402
 
403
- def __init__(self, client: Client, **kwargs):
 
 
 
 
 
 
404
  """
405
  Initializes the proxy server.
406
 
 
 
 
407
  Args:
408
- client: The FastMCP client connected to the backend server.
 
 
 
 
409
  **kwargs: Additional settings for the FastMCP server.
410
  """
 
411
  super().__init__(**kwargs)
412
- self.client = client
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
 
414
  # Replace the default managers with our specialized proxy managers.
415
- self._tool_manager = ProxyToolManager(client=self.client)
416
- self._resource_manager = ProxyResourceManager(client=self.client)
417
- self._prompt_manager = ProxyPromptManager(client=self.client)
 
 
418
 
419
 
420
  async def default_proxy_roots_handler(
@@ -435,15 +489,14 @@ class ProxyClient(Client[ClientTransportT]):
435
 
436
  def __init__(
437
  self,
438
- transport: (
439
- ClientTransportT
440
- | FastMCP
441
- | AnyUrl
442
- | Path
443
- | MCPConfig
444
- | dict[str, Any]
445
- | str
446
- ),
447
  **kwargs,
448
  ):
449
  if "roots" not in kwargs:
@@ -456,7 +509,7 @@ class ProxyClient(Client[ClientTransportT]):
456
  kwargs["log_handler"] = ProxyClient.default_log_handler
457
  if "progress_handler" not in kwargs:
458
  kwargs["progress_handler"] = ProxyClient.default_progress_handler
459
- super().__init__(transport, **kwargs)
460
 
461
  @classmethod
462
  async def default_sampling_handler(
 
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
 
18
  )
19
  from pydantic.networks import AnyUrl
20
 
21
+ import fastmcp
22
+ from fastmcp.client.client import Client, FastMCP1Server
23
  from fastmcp.client.elicitation import ElicitResult
24
  from fastmcp.client.logging import LogMessage
25
  from fastmcp.client.roots import RootsList
 
47
  class ProxyToolManager(ToolManager):
48
  """A ToolManager that sources its tools from a remote client in addition to local and mounted tools."""
49
 
50
+ def __init__(self, client_factory: Callable[[], Client], **kwargs):
51
  super().__init__(**kwargs)
52
+ self.client_factory = client_factory
53
 
54
  async def get_tools(self) -> dict[str, Tool]:
55
  """Gets the unfiltered tool inventory including local, mounted, and proxy tools."""
 
58
 
59
  # Then add proxy tools, but don't overwrite existing ones
60
  try:
61
+ client = self.client_factory()
62
+ async with client:
63
+ client_tools = await client.list_tools()
64
  for tool in client_tools:
65
  if tool.name not in all_tools:
66
+ all_tools[tool.name] = ProxyTool.from_mcp_tool(client, tool)
 
 
67
  except McpError as e:
68
  if e.error.code == METHOD_NOT_FOUND:
69
  pass # No tools available from proxy
 
84
  return await super().call_tool(key, arguments)
85
  except NotFoundError:
86
  # If not found locally, try proxy
87
+ client = self.client_factory()
88
+ async with client:
89
+ result = await client.call_tool(key, arguments)
90
  return ToolResult(
91
  content=result.content,
92
  structured_content=result.structured_content,
 
96
  class ProxyResourceManager(ResourceManager):
97
  """A ResourceManager that sources its resources from a remote client in addition to local and mounted resources."""
98
 
99
+ def __init__(self, client_factory: Callable[[], Client], **kwargs):
100
  super().__init__(**kwargs)
101
+ self.client_factory = client_factory
102
 
103
  async def get_resources(self) -> dict[str, Resource]:
104
  """Gets the unfiltered resource inventory including local, mounted, and proxy resources."""
 
107
 
108
  # Then add proxy resources, but don't overwrite existing ones
109
  try:
110
+ client = self.client_factory()
111
+ async with client:
112
+ client_resources = await client.list_resources()
113
  for resource in client_resources:
114
  if str(resource.uri) not in all_resources:
115
  all_resources[str(resource.uri)] = (
116
+ ProxyResource.from_mcp_resource(client, resource)
117
  )
118
  except McpError as e:
119
  if e.error.code == METHOD_NOT_FOUND:
 
130
 
131
  # Then add proxy templates, but don't overwrite existing ones
132
  try:
133
+ client = self.client_factory()
134
+ async with client:
135
+ client_templates = await client.list_resource_templates()
136
  for template in client_templates:
137
  if template.uriTemplate not in all_templates:
138
  all_templates[template.uriTemplate] = (
139
+ ProxyTemplate.from_mcp_template(client, template)
140
  )
141
  except McpError as e:
142
  if e.error.code == METHOD_NOT_FOUND:
 
163
  return await super().read_resource(uri)
164
  except NotFoundError:
165
  # If not found locally, try proxy
166
+ client = self.client_factory()
167
+ async with client:
168
+ result = await client.read_resource(uri)
169
  if isinstance(result[0], TextResourceContents):
170
  return result[0].text
171
  elif isinstance(result[0], BlobResourceContents):
 
177
  class ProxyPromptManager(PromptManager):
178
  """A PromptManager that sources its prompts from a remote client in addition to local and mounted prompts."""
179
 
180
+ def __init__(self, client_factory: Callable[[], Client], **kwargs):
181
  super().__init__(**kwargs)
182
+ self.client_factory = client_factory
183
 
184
  async def get_prompts(self) -> dict[str, Prompt]:
185
  """Gets the unfiltered prompt inventory including local, mounted, and proxy prompts."""
 
188
 
189
  # Then add proxy prompts, but don't overwrite existing ones
190
  try:
191
+ client = self.client_factory()
192
+ async with client:
193
+ client_prompts = await client.list_prompts()
194
  for prompt in client_prompts:
195
  if prompt.name not in all_prompts:
196
  all_prompts[prompt.name] = ProxyPrompt.from_mcp_prompt(
197
+ client, prompt
198
  )
199
  except McpError as e:
200
  if e.error.code == METHOD_NOT_FOUND:
 
220
  return await super().render_prompt(name, arguments)
221
  except NotFoundError:
222
  # If not found locally, try proxy
223
+ client = self.client_factory()
224
+ async with client:
225
+ result = await client.get_prompt(name, arguments)
226
  return result
227
 
228
 
 
253
  context: Context | None = None,
254
  ) -> ToolResult:
255
  """Executes the tool by making a call through the client."""
 
256
  async with self._client:
257
  result = await self._client.call_tool_mcp(
258
  name=self.name,
 
274
  _client: Client
275
  _value: str | bytes | None = None
276
 
277
+ def __init__(
278
+ self,
279
+ client: Client,
280
+ *,
281
+ _value: str | bytes | None = None,
282
+ **kwargs,
283
+ ):
284
  super().__init__(**kwargs)
285
  self._client = client
286
  self._value = _value
287
 
288
  @classmethod
289
  def from_mcp_resource(
290
+ cls,
291
+ client: Client,
292
+ mcp_resource: mcp.types.Resource,
293
  ) -> ProxyResource:
294
  """Factory method to create a ProxyResource from a raw MCP resource schema."""
295
  return cls(
 
412
  class FastMCPProxy(FastMCP):
413
  """
414
  A FastMCP server that acts as a proxy to a remote MCP-compliant server.
415
+ It uses specialized managers that fulfill requests via a client factory.
416
  """
417
 
418
+ def __init__(
419
+ self,
420
+ client: Client | None = None,
421
+ *,
422
+ client_factory: Callable[[], Client] | None = None,
423
+ **kwargs,
424
+ ):
425
  """
426
  Initializes the proxy server.
427
 
428
+ FastMCPProxy requires explicit session management via client_factory.
429
+ Use FastMCP.as_proxy() for convenience with automatic session strategy.
430
+
431
  Args:
432
+ client: [DEPRECATED] A Client instance. Use client_factory instead for explicit
433
+ session management. When provided, a client_factory will be automatically
434
+ created that provides session isolation for backwards compatibility.
435
+ client_factory: A callable that returns a Client instance when called.
436
+ This gives you full control over session creation and reuse.
437
  **kwargs: Additional settings for the FastMCP server.
438
  """
439
+
440
  super().__init__(**kwargs)
441
+
442
+ # Handle client and client_factory parameters
443
+ if client is not None and client_factory is not None:
444
+ raise ValueError("Cannot specify both 'client' and 'client_factory'")
445
+
446
+ if client is not None:
447
+ # Deprecated in 2.10.3
448
+ if fastmcp.settings.deprecation_warnings:
449
+ warnings.warn(
450
+ "Passing 'client' to FastMCPProxy is deprecated. Use 'client_factory' instead for explicit session management. "
451
+ "For automatic session strategy, use FastMCP.as_proxy().",
452
+ DeprecationWarning,
453
+ stacklevel=2,
454
+ )
455
+
456
+ # Create a factory that provides session isolation for backwards compatibility
457
+ def deprecated_client_factory():
458
+ return client.new()
459
+
460
+ self.client_factory = deprecated_client_factory
461
+ elif client_factory is not None:
462
+ self.client_factory = client_factory
463
+ else:
464
+ raise ValueError("Must specify 'client_factory'")
465
 
466
  # Replace the default managers with our specialized proxy managers.
467
+ self._tool_manager = ProxyToolManager(client_factory=self.client_factory)
468
+ self._resource_manager = ProxyResourceManager(
469
+ client_factory=self.client_factory
470
+ )
471
+ self._prompt_manager = ProxyPromptManager(client_factory=self.client_factory)
472
 
473
 
474
  async def default_proxy_roots_handler(
 
489
 
490
  def __init__(
491
  self,
492
+ transport: ClientTransportT
493
+ | FastMCP
494
+ | FastMCP1Server
495
+ | AnyUrl
496
+ | Path
497
+ | MCPConfig
498
+ | dict[str, Any]
499
+ | str,
 
500
  **kwargs,
501
  ):
502
  if "roots" not in kwargs:
 
509
  kwargs["log_handler"] = ProxyClient.default_log_handler
510
  if "progress_handler" not in kwargs:
511
  kwargs["progress_handler"] = ProxyClient.default_progress_handler
512
+ super().__init__(**kwargs | dict(transport=transport))
513
 
514
  @classmethod
515
  async def default_sampling_handler(
src/fastmcp/server/server.py CHANGED
@@ -1931,10 +1931,39 @@ class FastMCP(Generic[LifespanResultT]):
1931
 
1932
  if isinstance(backend, Client):
1933
  client = backend
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1934
  else:
1935
- client = ProxyClient(backend)
 
 
 
 
 
 
1936
 
1937
- return FastMCPProxy(client=client, **settings)
1938
 
1939
  @classmethod
1940
  def from_client(
 
1931
 
1932
  if isinstance(backend, Client):
1933
  client = backend
1934
+ # Session strategy based on client connection state:
1935
+ # - Connected clients: reuse existing session for all requests
1936
+ # - Disconnected clients: create fresh sessions per request for isolation
1937
+ if client.is_connected():
1938
+ from fastmcp.utilities.logging import get_logger
1939
+
1940
+ logger = get_logger(__name__)
1941
+ logger.info(
1942
+ "Proxy detected connected client - reusing existing session for all requests. "
1943
+ "This may cause context mixing in concurrent scenarios."
1944
+ )
1945
+
1946
+ # Reuse sessions - return the same client instance
1947
+ def reuse_client_factory():
1948
+ return client
1949
+
1950
+ client_factory = reuse_client_factory
1951
+ else:
1952
+ # Fresh sessions per request
1953
+ def fresh_client_factory():
1954
+ return client.new()
1955
+
1956
+ client_factory = fresh_client_factory
1957
  else:
1958
+ base_client = ProxyClient(backend)
1959
+
1960
+ # Fresh client created from transport - use fresh sessions per request
1961
+ def proxy_client_factory():
1962
+ return base_client.new()
1963
+
1964
+ client_factory = proxy_client_factory
1965
 
1966
+ return FastMCPProxy(client_factory=client_factory, **settings)
1967
 
1968
  @classmethod
1969
  def from_client(
tests/client/test_client.py CHANGED
@@ -449,27 +449,27 @@ async def test_client_nested_context_manager(fastmcp_server):
449
 
450
  # Before connection
451
  assert not client.is_connected()
452
- assert client._session is None
453
 
454
  # During connection
455
  async with client:
456
  assert client.is_connected()
457
- assert client._session is not None
458
- session = client._session
459
 
460
  # Re-use the same session
461
  async with client:
462
  assert client.is_connected()
463
- assert client._session is session
464
 
465
  # Re-use the same session
466
  async with client:
467
  assert client.is_connected()
468
- assert client._session is session
469
 
470
  # After connection
471
  assert not client.is_connected()
472
- assert client._session is None
473
 
474
 
475
  async def test_concurrent_client_context_managers():
 
449
 
450
  # Before connection
451
  assert not client.is_connected()
452
+ assert client._session_state.session is None
453
 
454
  # During connection
455
  async with client:
456
  assert client.is_connected()
457
+ assert client._session_state.session is not None
458
+ session = client._session_state.session
459
 
460
  # Re-use the same session
461
  async with client:
462
  assert client.is_connected()
463
+ assert client._session_state.session is session
464
 
465
  # Re-use the same session
466
  async with client:
467
  assert client.is_connected()
468
+ assert client._session_state.session is session
469
 
470
  # After connection
471
  assert not client.is_connected()
472
+ assert client._session_state.session is None
473
 
474
 
475
  async def test_concurrent_client_context_managers():
tests/deprecated/test_proxy_client.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for deprecated FastMCPProxy client parameter."""
2
+
3
+ import warnings
4
+
5
+ import pytest
6
+
7
+ from fastmcp import Client, FastMCP
8
+ from fastmcp.server.proxy import FastMCPProxy, ProxyClient
9
+
10
+
11
+ @pytest.fixture
12
+ def simple_server():
13
+ """Create a simple FastMCP server for testing."""
14
+ server = FastMCP("TestServer")
15
+
16
+ @server.tool
17
+ def simple_tool() -> str:
18
+ return "test_result"
19
+
20
+ return server
21
+
22
+
23
+ class TestDeprecatedClientParameter:
24
+ """Test the deprecated client parameter in FastMCPProxy."""
25
+
26
+ def test_client_parameter_deprecation_warning(self, simple_server):
27
+ """Test that using the client parameter raises a deprecation warning."""
28
+ client = Client(simple_server)
29
+
30
+ with warnings.catch_warnings(record=True) as w:
31
+ warnings.simplefilter("always") # Ensure all warnings are captured
32
+
33
+ FastMCPProxy(client=client)
34
+
35
+ # Verify a deprecation warning was raised
36
+ assert len(w) == 1
37
+ assert issubclass(w[0].category, DeprecationWarning)
38
+ assert "client' to FastMCPProxy is deprecated" in str(w[0].message)
39
+ assert "client_factory" in str(w[0].message)
40
+
41
+ def test_client_parameter_still_works(self, simple_server):
42
+ """Test that the deprecated client parameter still functions."""
43
+ client = ProxyClient(simple_server)
44
+
45
+ with warnings.catch_warnings():
46
+ warnings.simplefilter("ignore") # Suppress warnings for functionality test
47
+
48
+ proxy = FastMCPProxy(client=client)
49
+
50
+ # Verify the proxy was created successfully
51
+ assert proxy is not None
52
+ assert hasattr(proxy, "client_factory")
53
+ assert callable(proxy.client_factory)
54
+
55
+ # Verify the factory returns a new client instance (session isolation for backwards compatibility)
56
+ returned_client = proxy.client_factory()
57
+ assert returned_client is not client
58
+ assert isinstance(returned_client, type(client))
59
+
60
+ def test_cannot_specify_both_client_and_factory(self, simple_server):
61
+ """Test that specifying both client and client_factory raises an error."""
62
+ client = Client(simple_server)
63
+
64
+ def factory():
65
+ return Client(simple_server)
66
+
67
+ with pytest.raises(
68
+ ValueError, match="Cannot specify both 'client' and 'client_factory'"
69
+ ):
70
+ FastMCPProxy(client=client, client_factory=factory)
71
+
72
+ def test_must_specify_client_factory_when_no_client(self):
73
+ """Test that client_factory is required when client is not provided."""
74
+ with pytest.raises(ValueError, match="Must specify 'client_factory'"):
75
+ FastMCPProxy()
76
+
77
+ def test_client_factory_preferred_over_deprecated_client(self, simple_server):
78
+ """Test that the recommended client_factory approach works without warnings."""
79
+
80
+ def factory():
81
+ return ProxyClient(simple_server)
82
+
83
+ with warnings.catch_warnings(record=True) as w:
84
+ warnings.simplefilter("always")
85
+
86
+ proxy = FastMCPProxy(client_factory=factory)
87
+
88
+ # Verify no warnings were raised
89
+ assert len(w) == 0
90
+
91
+ # Verify the proxy works correctly
92
+ assert proxy is not None
93
+ assert proxy.client_factory is factory
94
+
95
+ async def test_deprecated_client_functional_test(self, simple_server):
96
+ """End-to-end test that deprecated client parameter still works functionally."""
97
+ client = ProxyClient(simple_server)
98
+
99
+ with warnings.catch_warnings():
100
+ warnings.simplefilter("ignore")
101
+
102
+ proxy = FastMCPProxy(client=client)
103
+
104
+ # Test that the proxy can actually handle requests
105
+ async with Client(proxy) as proxy_client:
106
+ result = await proxy_client.call_tool("simple_tool", {})
107
+ assert result.data == "test_result"
tests/server/proxy/__init__.py ADDED
File without changes
tests/server/proxy/test_proxy_client.py CHANGED
@@ -2,6 +2,7 @@ from dataclasses import dataclass
2
  from typing import cast
3
 
4
  import pytest
 
5
  from mcp.types import LoggingLevel, ModelHint, ModelPreferences, TextContent
6
 
7
  from fastmcp import Client, Context, FastMCP
@@ -255,3 +256,104 @@ class TestProxyClient:
255
  await client.call_tool("report_progress", {})
256
 
257
  assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from typing import cast
3
 
4
  import pytest
5
+ from anyio import create_task_group
6
  from mcp.types import LoggingLevel, ModelHint, ModelPreferences, TextContent
7
 
8
  from fastmcp import Client, Context, FastMCP
 
256
  await client.call_tool("report_progress", {})
257
 
258
  assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES
259
+
260
+ async def test_concurrent_log_requests_no_mixing(self, proxy_server: FastMCP):
261
+ """Test that concurrent log requests don't mix handlers (fixes #1068)."""
262
+ results: dict[str, LogMessage] = {}
263
+
264
+ async def log_handler_a(message: LogMessage) -> None:
265
+ results["logger_a"] = message
266
+
267
+ async def log_handler_b(message: LogMessage) -> None:
268
+ results["logger_b"] = message
269
+
270
+ async with (
271
+ Client(proxy_server, log_handler=log_handler_a) as client_a,
272
+ Client(proxy_server, log_handler=log_handler_b) as client_b,
273
+ ):
274
+ async with create_task_group() as tg:
275
+ tg.start_soon(
276
+ client_a.call_tool,
277
+ "log",
278
+ {"message": "Hello, world!", "level": "info", "logger": "a"},
279
+ )
280
+ tg.start_soon(
281
+ client_b.call_tool,
282
+ "log",
283
+ {"message": "Hello, world!", "level": "info", "logger": "b"},
284
+ )
285
+
286
+ assert results["logger_a"].logger == "a"
287
+ assert results["logger_b"].logger == "b"
288
+
289
+ async def test_concurrent_elicitation_no_mixing(self, proxy_server: FastMCP):
290
+ """Test that concurrent elicitation requests don't mix handlers (fixes #1068)."""
291
+ results = {}
292
+
293
+ async def elicitation_handler_a(
294
+ message: str,
295
+ response_type: type,
296
+ params: ElicitRequestParams,
297
+ ctx: RequestContext,
298
+ ) -> ElicitResult:
299
+ return ElicitResult(action="accept", content=response_type(name="Alice"))
300
+
301
+ async def elicitation_handler_b(
302
+ message: str,
303
+ response_type: type,
304
+ params: ElicitRequestParams,
305
+ ctx: RequestContext,
306
+ ) -> ElicitResult:
307
+ return ElicitResult(action="accept", content=response_type(name="Bob"))
308
+
309
+ async def get_and_store(name, coro):
310
+ result = await coro
311
+ results[name] = result.data
312
+
313
+ async with (
314
+ Client(proxy_server, elicitation_handler=elicitation_handler_a) as client_a,
315
+ Client(proxy_server, elicitation_handler=elicitation_handler_b) as client_b,
316
+ ):
317
+ async with create_task_group() as tg:
318
+ tg.start_soon(
319
+ get_and_store,
320
+ "elicitation_a",
321
+ client_a.call_tool("elicit", {}),
322
+ )
323
+ tg.start_soon(
324
+ get_and_store,
325
+ "elicitation_b",
326
+ client_b.call_tool("elicit", {}),
327
+ )
328
+
329
+ assert results["elicitation_a"] == "Hello, Alice!"
330
+ assert results["elicitation_b"] == "Hello, Bob!"
331
+
332
+ async def test_client_factory_creates_fresh_sessions(self, fastmcp_server: FastMCP):
333
+ """Test that the client factory pattern creates fresh sessions for each request."""
334
+ from fastmcp.server.proxy import FastMCPProxy
335
+
336
+ # Create a disconnected client (should use fresh sessions per request)
337
+ base_client = Client(fastmcp_server)
338
+
339
+ # Test both as_proxy convenience method and direct client_factory usage
340
+ proxy_via_as_proxy = FastMCP.as_proxy(base_client)
341
+ proxy_via_factory = FastMCPProxy(client_factory=base_client.new)
342
+
343
+ # Verify the proxies are created successfully - this tests the client factory pattern
344
+ assert proxy_via_as_proxy is not None
345
+ assert proxy_via_factory is not None
346
+
347
+ # Verify they have the expected client factory behavior
348
+ assert hasattr(proxy_via_as_proxy, "_tool_manager")
349
+ assert hasattr(proxy_via_factory, "_tool_manager")
350
+
351
+ async def test_connected_client_reuses_sessions(self, fastmcp_server: FastMCP):
352
+ """Test that connected clients passed to as_proxy reuse sessions (preserves #959 behavior)."""
353
+ # Create a connected client (should reuse sessions)
354
+ async with Client(fastmcp_server) as connected_client:
355
+ proxy = FastMCP.as_proxy(connected_client)
356
+
357
+ # Verify the proxy is created successfully and uses session reuse
358
+ assert proxy is not None
359
+ assert hasattr(proxy, "_tool_manager")
tests/server/{test_proxy.py → proxy/test_proxy_server.py} RENAMED
@@ -106,8 +106,8 @@ def test_as_proxy_with_url():
106
  """FastMCP.as_proxy should accept a URL without connecting."""
107
  proxy = FastMCP.as_proxy("http://example.com/mcp/")
108
  assert isinstance(proxy, FastMCPProxy)
109
- assert isinstance(proxy.client.transport, StreamableHttpTransport)
110
- assert proxy.client.transport.url == "http://example.com/mcp/"
111
 
112
 
113
  class TestTools:
 
106
  """FastMCP.as_proxy should accept a URL without connecting."""
107
  proxy = FastMCP.as_proxy("http://example.com/mcp/")
108
  assert isinstance(proxy, FastMCPProxy)
109
+ assert isinstance(proxy.client_factory().transport, StreamableHttpTransport)
110
+ assert proxy.client_factory().transport.url == "http://example.com/mcp/" # type: ignore[attr-defined]
111
 
112
 
113
  class TestTools: