modelcontextprotocol / modelcontextprotocol/python-sdk

client's `read_stream_writer` open after SSE disconnection hanging `.receive()`

オープン
#1,811 コメント 3 件 リアクション 0 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

bug P1 ready for work
主要言語
Python
スター
24.3k
フォーク
4k
平均マージ
1日 1時間
マージ済み PR(30日)
31

説明

Initial Checks
Description

This issue MAY be related to the following

Experiencing deadlocks on streamable_http transport. In order to reproduce the issue the following can be run.

import asyncio
import logging
import threading
import time

from fastmcp import Client, FastMCP
from fastmcp.client.transports import StreamableHttpTransport

logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
    handlers=[logging.StreamHandler()],
)

logger = logging.getLogger(__name__)

HOST = "127.0.0.1"
PORT = 8765
SERVER_URL = f"http://{HOST}:{PORT}/mcp"

mcp = FastMCP(name="timeout issue")

SSE_TIMEOUT = 0.1
SLEEP = 60


@mcp.tool
def blocking_call() -> str:
    time.sleep(SLEEP)
    return "42"


def run_server():
    mcp.run(transport="streamable-http", host=HOST, port=PORT)


async def test_blocking_sse():
    transport = StreamableHttpTransport(SERVER_URL, sse_read_timeout=SSE_TIMEOUT)
    async with Client(transport) as client:
        tools = await client.list_tools()
        print(f"available tools: {[t.name for t in tools]}")
        result = await client.call_tool("blocking_call", {})
        print(f"blocking result: {result}")


if __name__ == "__main__":
    server_thread = threading.Thread(target=run_server, daemon=True)
    server_thread.start()

    time.sleep(2)

    try:
        asyncio.run(test_blocking_sse())
    finally:
        logger.info(f"{time.strftime('%Y-%m-%d %H:%M:%S')} [CLIENT] Shutting down...")

Execution context := locally built fastmcp at this commit 790ea92

Observed Behavior: the client hangs forever after the SSE read timeout fires, looking at the logs:

  • tool request is sent, server returns HTTP 200 with [Content-Type: text/event-stream]
  • server starts executing the tool (blocking time.sleep(60))
  • client's SSE read timeout fires (after ~5 seconds with default httpx timeout)
  • client closes the HTTP connection
  • client hangs indefinitely - call_tool() never returns
  • the tool eventually completes on the server side, but when the server tries to send the response back, it gets BrokenResourceError because the HTTP connection was already closed by the client.

I know noting about nothing, so I can imagine the above example is just an issue on my side, maybe:

  • sse timeout should always be greater or equal to the expected timeout of tool calls (now that sse_read_timeout is deprecated it's httpx.Timeout counterpart should be >= tool timeout)
  • long running tool calls MUST send progress updates

but one thing is for sure and that is, the above configuration hangs indefenitely because the session layer never gets to know that the transport layer is dead after the SSE stream is closed

Within my ignorance of many aspects of the implementation, the missing else branch seems to be the root cause.

https://github.com/modelcontextprotocol/python-sdk/blob/a9cc822a1051b1bd2b6b9b57e9e4136406983b61/src/mcp/client/streamable_http.py#L433-L435

and the fix looks something like

if last_event_id is not None:  # pragma: no branch 
    ...
else:
    error_response = JSONRPCError(...)
    await ctx.read_stream_writer.send(SessionMessage(JSONRPCMessage(root=error_response)))

consequently raising McpError

Example Code

Python & MCP Python SDK
Execution context for ease of implementation 
- `mcp==1.24.0`
- example codebase run against locally built `fastmcp` at [790ea92](https://github.com/jlowin/fastmcp/tree/790ea92eb59256da68c83097321ebde8f8819bcf) --> 1.24.0
- Python: `python-3.12.7-macos-aarch64-none/bin/python3.12`

コントリビューションガイド

コントリビューションガイドを開く

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

調査の方向性

src/mcp/client/streamable_http.py の 433-435 行付近から始め、StreamableHttpTransport を使用する Python の例でハングする呼び出しを再現します。SSE の読み取りタイムアウトが接続を閉じる仕組みと、セッションが read_stream_writer のメッセージを消費する仕組みを追跡します。タイムアウトしたクライアント呼び出しが無期限にハングせず、戻るか例外を送出すれば完了です。

索引モデルが issue の本文から書いたものです。

評価

技術スタック
python
領域
api, networking
issue の種類
バグ
難易度
3/5
見積もり時間
1〜2日
活発さ
停滞
明瞭さ
おおむね明確
初心者へのやさしさ
42/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。