modelcontextprotocol / modelcontextprotocol/typescript-sdk

Feature: add idle timeout to SSE stream reader in StreamableHTTPClientTransport

Open
#1,883 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement needs decision P3
Dominant language
TypeScript
Stars
13.4k
Forks
2.2k
Avg merge
3d 15h
Merged PRs (30d)
4

Description

I ran into a problem where the SSE stream in StreamableHTTPClientTransport hangs indefinitely when the server starts streaming a response but then stops sending data (half-open TCP connection, server crashes mid-stream, proxy stalls, etc).

What happens

_handleSseStream reads from the SSE stream in a loop:

while (true) {
  const { value: event, done } = await reader.read();  // blocks forever if stream stalls
  if (done) break;
  // process event...
}

If the connection goes quiet (server stalls, proxy drops, network hiccup), reader.read() blocks indefinitely. There's no idle timeout, no chunk timeout, no timer of any kind.

On my setup (OpenCode Desktop via oh-my-openagent, hitting Chinese LLM providers like GLM/Qwen), this happens multiple times per day. The agent GUI shows "thinking" and never recovers — I've seen hangs over 10 minutes before giving up and restarting.

Why the caller's AbortSignal doesn't help

The caller might pass an AbortSignal (e.g. a 120s prompt timeout), but send() uses its own internal _abortController.signal for the fetch call:

async send(message, options) {
  const init = {
    signal: this._abortController?.signal  // internal signal, ignores caller's timeout
  };
  const response = await fetch(this._url, init);
  // ... if response is text/event-stream, hands off to _handleSseStream
}

The caller's signal never reaches _handleSseStream or its reader.

What I'd suggest

Add an optional idle timeout to _handleSseStream. If no SSE chunk arrives within the timeout, cancel the reader and let the reconnection logic handle it. Something like:

const SSE_IDLE_TIMEOUT_MS = options.idleTimeoutMs ?? 60_000;
let idleTimer;
const resetIdleTimer = () => {
  clearTimeout(idleTimer);
  idleTimer = setTimeout(() => {
    reader.cancel(new Error(`SSE idle timeout after ${SSE_IDLE_TIMEOUT_MS}ms`));
  }, SSE_IDLE_TIMEOUT_MS);
};
resetIdleTimer();
while (true) {
  const { value: event, done } = await reader.read();
  if (done) { clearTimeout(idleTimer); break; }
  resetIdleTimer();
  // ... process event
}

This would also need cleanup in the catch block.

My workaround

I'm currently patching _handleSseStream in my local oh-my-openagent/dist/index.js with essentially the code above (60s idle timeout). It works — the agent recovers within ~60s instead of hanging forever. But it'd be much better to have this upstream.

Thanks for the great work on this SDK!

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start at _handleSseStream in StreamableHTTPClientTransport and trace how reader failures reach the reconnection logic. Add coverage for a stalled SSE reader and verify that an idle stream is cancelled after the configured timeout, with timers cleaned up when the stream ends or errors.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
networking
Issue type
Feature
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.