Skip to content

StreamableHttpTransport creates new session for each connection in MCPConfigTransport #2790

@huiwy

Description

@huiwy

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 session

Every time connect_session() is called:

  1. A new MCPStreamableHTTPTransport instance is created (inside streamablehttp_client())
  2. This new instance has session_id = None initially
  3. It establishes a new session with the server
  4. 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 MCPStreamableHTTPTransport instance (different transport_id)
  • Each new instance receives a different session_id from the server
  • The StreamableHttpTransport wrapper 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 correctly

Related 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working. Reports of errors, unexpected behavior, or broken functionality.clientRelated to the FastMCP client SDK or client-side functionality.httpRelated to HTTP transport, networking, or web server functionality.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions