2025, Nov 06 09:00

FastMCP streamable-http handshake hangs: fix ClientSession initialize timeout with async with and proper content reading

Troubleshoot FastMCP streamable-http timeouts: use async with ClientSession to complete the handshake and read tool results from content or structuredContent.

FastMCP over streamable-http sometimes looks healthy on the server side yet leaves the client waiting forever on the handshake. If your client times out on session initialization while SSE works, the culprit may be how the ClientSession lifecycle is managed and how you read tool outputs.

Minimal setup that reproduces the timeout

The server exposes a single tool and is started with the streamable-http transport:

from mcp.server.fastmcp import FastMCP
node = FastMCP(
    name="Example-FastMCP",
    streamable_http_path="/mcp",
)
@node.tool()
def add(x: int, y: int) -> int:
    return x + y
if __name__ == "__main__":
    node.run(transport="streamable-http")

The client connects to that endpoint. In this version, the session is created without an async context manager, and the initialization hangs until a timeout:

import asyncio
from datetime import timedelta
from mcp.client.streamable_http import streamablehttp_client
from mcp.client.session import ClientSession
ENDPOINT = "http://127.0.0.1:8000/mcp/"
async def run_client() -> None:
    async with streamablehttp_client(
        url=ENDPOINT,
        timeout=100,
        sse_read_timeout=300,
    ) as (rx, tx, get_sid):
        sess = ClientSession(
            read_stream=rx,
            write_stream=tx,
            read_timeout_seconds=timedelta(seconds=100),
        )
        init_info = await sess.initialize()  # <-- times out here
        print("protocol:", init_info.protocolVersion)
        print("session:", get_sid())
        tools_meta = await sess.list_tools()
        fn_list = [t.name for t in tools_meta.tools]
        print("tools:", fn_list)
        res = await sess.call_tool("add", {"x": 2, "y": 3})
        print("add(2, 3) =", res.output[0].text)
if __name__ == "__main__":
    asyncio.run(run_client())

Client logs report a TimeoutError waiting for initialize, while the server shows a transport created and then the session getting terminated. Toggling json_response or stateless_http on the server does not change this behavior. SSE transport works, but it is deprecated and may not be viable behind an API Gateway.

What’s actually going on

The session stalls on initialize when the ClientSession is not used as an async context manager. Using async with ensures the session wires up correctly before you call initialize. Attempting to await the constructor directly is not supported; a line like session = await ClientSession(...) does not work.

Fix: use an async context for ClientSession and read content correctly

Wrapping ClientSession in async with resolves the hang on initialize. Additionally, tool results should be read from content (or structuredContent) rather than output:

import asyncio
from datetime import timedelta
from mcp.client.streamable_http import streamablehttp_client
from mcp.client.session import ClientSession
ENDPOINT = "http://127.0.0.1:8000/mcp/"
async def run_client_fixed() -> None:
    async with streamablehttp_client(
        url=ENDPOINT,
        timeout=100,
        sse_read_timeout=300,
    ) as (rx, tx, get_sid):
        async with ClientSession(
            read_stream=rx,
            write_stream=tx,
            read_timeout_seconds=timedelta(seconds=100),
        ) as sess:
            init_info = await sess.initialize()
            print("protocol:", init_info.protocolVersion)
            print("session:", get_sid())
            tools_meta = await sess.list_tools()
            fn_list = [t.name for t in tools_meta.tools]
            print("tools:", fn_list)
            res = await sess.call_tool("add", {"x": 2, "y": 3})
            print("add(2, 3) =", res.content[0].text)
            # or:
            # print("add(2, 3) =", res.structuredContent['result'])
if __name__ == "__main__":
    asyncio.run(run_client_fixed())

If you need to inspect the payload shape before choosing how to read it, just print(res) and check the available fields.

Why this matters

When streamable-http is the only viable transport, a subtle lifecycle mistake can look like a transport bug. Correct session management prevents long initialization hangs, avoids confusing timeouts, and keeps the flow compatible with gateways where SSE cannot be used.

Bottom line

If session.initialize times out, initialize the ClientSession inside async with instead of creating it bare, and read tool results from content or structuredContent rather than output. With these two adjustments, the FastMCP client and server handshake completes and tool calls return as expected.

The article is based on a question from StackOverflow by Sumit and an answer by furas.