Spaces:
Running
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 +192 -56
- docs/servers/server.mdx +2 -0
- src/fastmcp/client/client.py +140 -83
- src/fastmcp/server/proxy.py +100 -47
- src/fastmcp/server/server.py +31 -2
- tests/client/test_client.py +6 -6
- tests/deprecated/test_proxy_client.py +107 -0
- tests/server/proxy/__init__.py +0 -0
- tests/server/proxy/test_proxy_client.py +102 -0
- tests/server/{test_proxy.py → proxy/test_proxy_server.py} +2 -2
|
@@ -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—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 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
|
| 42 |
-
The
|
| 43 |
|
| 44 |
```python
|
| 45 |
from fastmcp import FastMCP
|
|
|
|
| 46 |
|
| 47 |
-
#
|
| 48 |
-
|
| 49 |
-
"backend_server.py",
|
| 50 |
-
name="
|
| 51 |
)
|
| 52 |
|
| 53 |
-
#
|
| 54 |
-
|
| 55 |
-
|
| 56 |
```
|
| 57 |
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
-
|
| 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 |
-
<
|
| 66 |
-
|
| 67 |
-
|
| 68 |
|
| 69 |
-
###
|
| 70 |
|
| 71 |
-
|
| 72 |
|
| 73 |
```python
|
| 74 |
-
from fastmcp import
|
| 75 |
|
| 76 |
-
#
|
| 77 |
-
proxy = FastMCP.as_proxy("
|
| 78 |
|
| 79 |
-
#
|
| 80 |
-
#
|
|
|
|
|
|
|
| 81 |
```
|
| 82 |
|
| 83 |
-
###
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
-
|
| 86 |
|
| 87 |
```python
|
| 88 |
from fastmcp import FastMCP
|
|
|
|
| 89 |
|
| 90 |
-
#
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
| 96 |
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
| 101 |
)
|
| 102 |
|
| 103 |
-
#
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
```
|
| 106 |
|
| 107 |
-
###
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 139 |
|
| 140 |
-
|
| 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 |
-
##
|
|
|
|
|
|
|
| 166 |
|
| 167 |
-
|
| 168 |
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
```
|
|
@@ -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
|
|
@@ -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 |
-
|
| 93 |
-
|
| 94 |
|
| 95 |
Args:
|
| 96 |
transport:
|
|
@@ -127,56 +148,65 @@ class Client(Generic[ClientTransportT]):
|
|
| 127 |
"""
|
| 128 |
|
| 129 |
@overload
|
| 130 |
-
def
|
| 131 |
-
cls,
|
| 132 |
-
transport: ClientTransportT,
|
| 133 |
-
**kwargs: Any,
|
| 134 |
-
) -> Client[ClientTransportT]: ...
|
| 135 |
|
| 136 |
@overload
|
| 137 |
-
def
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
@overload
|
| 142 |
-
def
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
@overload
|
| 147 |
-
def
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
@overload
|
| 152 |
-
def
|
| 153 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
@overload
|
| 157 |
-
def
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
return instance
|
| 169 |
|
| 170 |
def __init__(
|
| 171 |
self,
|
| 172 |
-
transport:
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 254 |
raise RuntimeError(
|
| 255 |
"Client is not connected. Use the 'async with client:' context manager first."
|
| 256 |
)
|
| 257 |
|
| 258 |
-
return self.
|
| 259 |
|
| 260 |
@property
|
| 261 |
def initialize_result(self) -> mcp.types.InitializeResult:
|
| 262 |
"""Get the result of the initialization request."""
|
| 263 |
-
if self.
|
| 264 |
raise RuntimeError(
|
| 265 |
"Client is not connected. Use the 'async with client:' context manager first."
|
| 266 |
)
|
| 267 |
-
return self.
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 298 |
# Initialize the session
|
| 299 |
try:
|
| 300 |
with anyio.fail_after(self._init_timeout):
|
| 301 |
-
self.
|
|
|
|
|
|
|
| 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.
|
| 309 |
-
self.
|
| 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.
|
| 332 |
-
need_to_start =
|
|
|
|
|
|
|
|
|
|
| 333 |
if need_to_start:
|
| 334 |
-
if self.
|
| 335 |
raise RuntimeError(
|
| 336 |
-
f"Internal error: nesting counter should be 0 when starting new session, got {self.
|
| 337 |
)
|
| 338 |
-
self.
|
| 339 |
-
self.
|
| 340 |
-
self.
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
|
|
|
|
|
|
| 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.
|
| 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.
|
| 374 |
# if we are forcing a disconnect, reset the nesting counter
|
| 375 |
if force:
|
| 376 |
-
self.
|
| 377 |
|
| 378 |
# otherwise decrement to check if we are done nesting
|
| 379 |
else:
|
| 380 |
-
self.
|
|
|
|
|
|
|
| 381 |
|
| 382 |
# if we are still nested, return
|
| 383 |
-
if self.
|
| 384 |
return
|
| 385 |
|
| 386 |
# stop the active seesion
|
| 387 |
-
if self.
|
| 388 |
return
|
| 389 |
-
self.
|
| 390 |
# wait for session to finish to ensure state has been reset
|
| 391 |
-
await self.
|
| 392 |
-
self.
|
| 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.
|
| 413 |
# Wait until disconnect/stop is requested
|
| 414 |
-
await self.
|
| 415 |
finally:
|
| 416 |
# Ensure ready event is set even if context manager entry fails
|
| 417 |
-
self.
|
| 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)
|
|
@@ -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 |
-
|
|
|
|
| 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,
|
| 48 |
super().__init__(**kwargs)
|
| 49 |
-
self.
|
| 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 |
-
|
| 59 |
-
|
|
|
|
| 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 |
-
|
| 86 |
-
|
|
|
|
| 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,
|
| 97 |
super().__init__(**kwargs)
|
| 98 |
-
self.
|
| 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 |
-
|
| 108 |
-
|
|
|
|
| 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(
|
| 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 |
-
|
| 130 |
-
|
|
|
|
| 131 |
for template in client_templates:
|
| 132 |
if template.uriTemplate not in all_templates:
|
| 133 |
all_templates[template.uriTemplate] = (
|
| 134 |
-
ProxyTemplate.from_mcp_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 |
-
|
| 162 |
-
|
|
|
|
| 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,
|
| 175 |
super().__init__(**kwargs)
|
| 176 |
-
self.
|
| 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 |
-
|
| 186 |
-
|
|
|
|
| 187 |
for prompt in client_prompts:
|
| 188 |
if prompt.name not in all_prompts:
|
| 189 |
all_prompts[prompt.name] = ProxyPrompt.from_mcp_prompt(
|
| 190 |
-
|
| 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 |
-
|
| 217 |
-
|
|
|
|
| 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__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
super().__init__(**kwargs)
|
| 272 |
self._client = client
|
| 273 |
self._value = _value
|
| 274 |
|
| 275 |
@classmethod
|
| 276 |
def from_mcp_resource(
|
| 277 |
-
cls,
|
|
|
|
|
|
|
| 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
|
| 401 |
"""
|
| 402 |
|
| 403 |
-
def __init__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
"""
|
| 405 |
Initializes the proxy server.
|
| 406 |
|
|
|
|
|
|
|
|
|
|
| 407 |
Args:
|
| 408 |
-
client:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
**kwargs: Additional settings for the FastMCP server.
|
| 410 |
"""
|
|
|
|
| 411 |
super().__init__(**kwargs)
|
| 412 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
|
| 414 |
# Replace the default managers with our specialized proxy managers.
|
| 415 |
-
self._tool_manager = ProxyToolManager(
|
| 416 |
-
self._resource_manager = ProxyResourceManager(
|
| 417 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 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__(
|
| 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(
|
|
@@ -1931,10 +1931,39 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1931 |
|
| 1932 |
if isinstance(backend, Client):
|
| 1933 |
client = backend
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1934 |
else:
|
| 1935 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1936 |
|
| 1937 |
-
return FastMCPProxy(
|
| 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(
|
|
@@ -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.
|
| 453 |
|
| 454 |
# During connection
|
| 455 |
async with client:
|
| 456 |
assert client.is_connected()
|
| 457 |
-
assert client.
|
| 458 |
-
session = client.
|
| 459 |
|
| 460 |
# Re-use the same session
|
| 461 |
async with client:
|
| 462 |
assert client.is_connected()
|
| 463 |
-
assert client.
|
| 464 |
|
| 465 |
# Re-use the same session
|
| 466 |
async with client:
|
| 467 |
assert client.is_connected()
|
| 468 |
-
assert client.
|
| 469 |
|
| 470 |
# After connection
|
| 471 |
assert not client.is_connected()
|
| 472 |
-
assert client.
|
| 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():
|
|
@@ -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"
|
|
File without changes
|
|
@@ -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")
|
|
@@ -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.
|
| 110 |
-
assert proxy.
|
| 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:
|