2025, Nov 08 12:03
Как исправить таймаут initialize в FastMCP streamable-http с ClientSession
Разбираем таймаут initialize в FastMCP на streamable-http: ошибка в жизненном цикле ClientSession. Покажем решение с async with и чтение результатов.
FastMCP поверх streamable-http иногда выглядит исправно на стороне сервера, но оставляет клиента бесконечно ждать рукопожатия. Если у клиента истекает время ожидания при инициализации сессии, тогда как SSE работает, причина может крыться в том, как управляется жизненный цикл ClientSession и как вы читаете результаты инструментов.
Минимальная конфигурация, воспроизводящая таймаут
Сервер публикует один инструмент и запускается с транспортом streamable-http:
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")
Клиент подключается к этому endpoint. В этой версии сессия создаётся без асинхронного контекстного менеджера, и инициализация зависает до таймаута:
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() # <-- здесь происходит таймаут
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())
Логи клиента сообщают о TimeoutError при ожидании initialize, тогда как сервер показывает, что транспорт создан, а затем сессия завершается. Переключение параметров json_response или stateless_http на сервере не меняет это поведение. Транспорт SSE работает, но он помечен как устаревший и может быть непригоден за API‑шлюзом.
Что происходит на самом деле
Сессия замирает на initialize, если ClientSession не используется как асинхронный контекстный менеджер. Использование async with обеспечивает корректную настройку сессии до вызова initialize. Попытка ожидать конструктор напрямую не поддерживается; строка вида session = await ClientSession(...) не работает.
Решение: используйте асинхронный контекст для ClientSession и корректно считывайте содержимое
Оборачивание ClientSession в async with устраняет зависание на initialize. Кроме того, результаты инструментов следует читать из content (или structuredContent), а не из 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)
# или:
# print("add(2, 3) =", res.structuredContent['result'])
if __name__ == "__main__":
asyncio.run(run_client_fixed())
Если нужно изучить форму полезной нагрузки, прежде чем выбирать способ чтения, просто выведите print(res) и посмотрите доступные поля.
Почему это важно
Когда streamable-http — единственный жизнеспособный транспорт, незаметная ошибка в жизненном цикле может выглядеть как сбой транспорта. Корректное управление сессией предотвращает долгие зависания на инициализации, избавляет от сбивающих с толку таймаутов и сохраняет совместимость с шлюзами, где SSE использовать нельзя.
Итог
Если session.initialize уходит в таймаут, инициализируйте ClientSession внутри async with вместо «голого» создания, а результаты инструментов читайте из content или structuredContent, а не из output. С этими двумя правками рукопожатие клиента и сервера FastMCP завершается успешно, а вызовы инструментов возвращают ожидаемый результат.