-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Description
Description
Problem Description
When using fastmcp.Client with streamable-http transport, each tool call creates a new session with the MCP server, resulting in a different session_id each time. This breaks session persistence and prevents stateful operations on the server side.
Steps to Reproduce
1. Setup a streamable-http MCP server (e.g., using fastmcp)
# server.py
from fastmcp import FastMCP
mcp = FastMCP("Test Server")
@mcp.tool()
def add(c: Context):
return {
"session_id": c.session_id,
"client_id": c.client_id
}
mcp.run(transport="streamable-http", host="0.0.0.0", port=8766)2. Create a client that calls the tool multiple times
# client.py
from fastmcp import Client
import asyncio
async def main():
client = Client({
"a": {
"transport": "streamable-http",
"url": "http://localhost:8766/mcp",
},
"b": {
"transport": "streamable-http",
"url": "http://localhost:8766/mcp",
},
})
async with client:
result1 = await client.call_tool("a_add", {})
print("Call 1:", result1.content[0].text)
result2 = await client.call_tool("a_add", {})
print("Call 2:", result2.content[0].text)
asyncio.run(main())3. Observe the output
Call 1: {"client_id":null,"session_id":"abc123..."}
Call 2: {"client_id":null,"session_id":"def456..."} # Different session_id!
Expected Behavior
Both calls should use the same session_id since they're within the same async with client: context:
Call 1: {"client_id":null,"session_id":"abc123..."}
Call 2: {"client_id":null,"session_id":"abc123..."} # Same session_id
Root Cause Analysis
The issue is in StreamableHttpTransport.connect_session() method (in fastmcp/client/transports.py):
@contextlib.asynccontextmanager
async def connect_session(self, **session_kwargs) -> AsyncIterator[ClientSession]:
# ... setup code ...
async with streamablehttp_client(
self.url,
auth=self.auth,
**client_kwargs,
) as transport:
read_stream, write_stream, _ = transport
async with ClientSession(
read_stream, write_stream, **session_kwargs
) as session:
yield sessionEvery time connect_session() is called:
- A new
MCPStreamableHTTPTransportinstance is created (insidestreamablehttp_client()) - This new instance has
session_id = Noneinitially - It establishes a new session with the server
- The server returns a new
session_id
When using ProxyClient or making multiple tool calls, connect_session() is invoked repeatedly, creating a new transport instance each time.
Evidence from Logging
I added instrumentation and confirmed:
- Each tool call creates a new
MCPStreamableHTTPTransportinstance (differenttransport_id) - Each new instance receives a different
session_idfrom the server - The
StreamableHttpTransportwrapper doesn't maintain any state between calls
Impact
This issue affects:
- Stateful operations: Server-side state tied to sessions is lost between calls
- ProxyClient scenarios: Multiple proxied servers create even more sessions
- Performance: Extra overhead from session establishment on each call
- Resource usage: Servers may accumulate orphaned sessions
Proposed Solution
Option 1: Cache transport instances per URL
Modify StreamableHttpTransport to cache MCPStreamableHTTPTransport instances per URL:
# Global cache
_transport_cache: Dict[str, MCPStreamableHTTPTransport] = {}
@contextlib.asynccontextmanager
async def connect_session(self, **session_kwargs):
cache_key = self.url
if cache_key not in _transport_cache:
_transport_cache[cache_key] = MCPStreamableHTTPTransport(
url=self.url,
headers=self.headers,
timeout=...,
sse_read_timeout=...,
auth=self.auth
)
cached_transport = _transport_cache[cache_key]
# Use cached_transport instead of creating new one
# ... rest of implementation ...Option 2: Store transport instance on the StreamableHttpTransport object
class StreamableHttpTransport(ClientTransport):
def __init__(self, ...):
# ... existing init ...
self._mcp_transport = None
@contextlib.asynccontextmanager
async def connect_session(self, **session_kwargs):
if self._mcp_transport is None:
self._mcp_transport = MCPStreamableHTTPTransport(...)
# Use self._mcp_transport ...Option 3: Modify streamablehttp_client to accept existing transport
Allow streamablehttp_client() to accept an optional existing StreamableHTTPTransport instance instead of always creating a new one.
Workaround
I've created a monkey patch that implements Option 1. Users can apply it as a temporary fix:
import fastmcp_streamable_session_fix # Apply patch before creating Client
from fastmcp import Client
# Now sessions persist correctlyRelated Files
fastmcp/client/transports.py-StreamableHttpTransport.connect_session()mcp/client/streamable_http.py-streamablehttp_client(),StreamableHTTPTransport
Example Code
Version Information
FastMCP version: 2.12.4
MCP version: 1.15.0
Python version: 3.10.14
Platform: Linux-5.4.0-125-generic-x86_64-with-glibc2.31