[BUG] v2.1.147 regression: HTTP MCP servers without optional GET listening stream - 404 misinterpreted as session expiry, transport torn down

Status Fixed / completed
Reported on v2.1.142
Maintainer reply ✓ Yes — localden
Activity 4 comments · opened May 25, 2026 · closed May 27, 2026
💡 Likely answer: A maintainer (localden, collaborator) responded on this thread — see the highlighted reply below.

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Starting in v2.1.147, Claude Code attempts to open the optional GET listening stream against an HTTP MCP server's URL. When the server returns 404 (a legitimate response per the MCP Streamable HTTP spec — the GET stream is optional), Claude Code now misinterprets it as MCP session expired, tears down the transport, retries in a reconnect loop, and ultimately fails tools/list with Not connected. The user sees the server silently absent from their session.

The same gateway works on v2.1.146 and earlier.

This breaks every HTTP MCP server that implements only the POST half of Streamable HTTP — which is allowed by spec.

This appears to be a side-effect of the auto-reconnect logic added in response to enhancement #59442 (closed) — the same logic that #62008 reports is also not working on its happy path. We landed on the over-broad trigger end of the same bug.

What Should Happen?

Per MCP Streamable HTTP transport spec §2 (server-to-client SSE) (https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http):

▎ Clients MAY issue an HTTP GET to the MCP endpoint. This can be used to open an SSE stream, allowing the server to communicate to the client, without the client first sending data via HTTP POST. […]
▎
▎ If the server does not offer an SSE stream at this endpoint, the server MUST return HTTP 405 Method Not Allowed.

So:

  • Clients MAY open a GET listening stream — it is optional, not required for correct operation.
  • The session-expiry mechanism in the spec (§session management clause 4 (https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#session-management)) applies to POST requests carrying an Mcp-Session-Id that the server has invalidated. A 404 to the initial GET stream-open attempt is not a session-expiry signal — the session id has nothing to do with it.

Expected client behavior:

  • Mcp-Session-Id-carrying POST receiving 404 → legitimate session expiry → reconnect. (This is the fix #59442 asked for, and what #62008 reports still doesn't work.)
  • Initial GET stream-open receiving 404 or 405 → "server doesn't offer the optional listening stream" → log and continue. The POST request channel remains valid and tool calls should proceed normally.

The forgiving behavior in v2.1.146 and earlier was already correct on the GET-stream-open side. The v2.1.147 change appears to have widened the session-expiry trigger to catch any 404 anywhere on the transport, which is too broad.

Error Messages/Logs

Side-by-side --debug log diff. Both versions issue the GET listening-stream open. Both receive 404 from the same gateway. The diff is the reaction.

v2.1.146 (working):

[DEBUG] MCP server "my-http-mcp": Successfully connected (transport: http) in 883ms
[DEBUG] MCP server "my-http-mcp": Connection error: Streamable HTTP error: Failed to open SSE stream: Not Found
[DEBUG] MCP server "my-http-mcp": Connection error: Streamable HTTP error: Failed to open SSE stream: Not Found
[DEBUG] MCP server "my-http-mcp": Channel notifications skipped: server did not declare claude/channel capability
... 15 seconds pass (user types prompt) ...
[DEBUG] MCP server "my-http-mcp": Calling MCP tool: jira_search_issues
[DEBUG] MCP server "my-http-mcp": Tool 'jira_search_issues' completed successfully in 538ms
v2.1.147 (broken) — lines added in v2.1.147 marked with +:

  [DEBUG] MCP server "my-http-mcp": Successfully connected (transport: http) in 327ms
  [DEBUG] MCP server "my-http-mcp": Channel notifications skipped: server did not declare claude/channel capability
  [DEBUG] MCP server "my-http-mcp": Connection error: Streamable HTTP error: Failed to open SSE stream: Not Found
+ [DEBUG] MCP server "my-http-mcp": MCP session expired (server returned 404), triggering reconnection
+ [DEBUG] MCP server "my-http-mcp": Closing transport (session expired)
+ [DEBUG] MCP server "my-http-mcp": HTTP transport closed/disconnected, attempting automatic reconnection
+ [DEBUG] MCP server "my-http-mcp": Connection error: Streamable HTTP error: Failed to open SSE stream: Not Found
+ ... reconnect loop, more 404s ...
+ [ERROR] MCP server "my-http-mcp" Failed to fetch tools: Not connected
+ [DEBUG] MCP server "my-http-mcp": Terminal connection error 1/3
The new MCP session expired (server returned 404), triggering reconnection branch is the regression. The 404 here is the response to the GET listening-stream open, which carries no Mcp-Session-Id and is not a session-expiry signal.

Steps to Reproduce

Minimal ~/.claude.json:

{
"mcpServers": {
"my-http-mcp": {
"type": "http",
"url": "https://<your-host>/mcp",
"headers": {
"Authorization": "Bearer <token>"
}
}
}
}
Server behavior (spec-compliant, POST-only — does not offer the optional GET listening stream):

┌────────────────────────────────────────┬─────────────────────────────────────────────────────────────────┐
│                Request                 │                            Response                             │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
│ POST /mcp (initialize)                 │ 200, returns protocolVersion: 2025-03-26, issues Mcp-Session-Id │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
│ POST /mcp (tools/list with session id) │ 200, returns tools                                              │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
│ GET /mcp (open listening stream)       │ 404                                                             │
├────────────────────────────────────────┼─────────────────────────────────────────────────────────────────┤
│ OPTIONS /mcp                           │ 200, Allow: POST                                                │
└────────────────────────────────────────┴─────────────────────────────────────────────────────────────────┘

Direct curl against the gateway works perfectly (initialize + notifications/initialized + tools/list all succeed).

In Claude Code v2.1.147+, the server appears in claude mcp list as ✓ Connected (because mcp list only does the initialize probe), but tools are absent from interactive sessions and --debug shows the reconnect loop above.

  1. Set up the config above pointing at any POST-only HTTP MCP server.
  2. Run claude mcp list — observe ✓ Connected (misleading).
  3. Open an interactive session: claude --debug.
  4. Ask Claude to call any tool from the server.
  5. Observe: tools are not surfaced; debug log shows MCP session expired → reconnect loop → Failed to fetch tools: Not connected.

Performed by extracting platform binaries from the published npm tarballs (@anthropic-ai/claude-code-linux-x64@2.1.X) and running each side-by-side against the same MCP gateway with the same ~/.claude.json config:

┌─────────┬────────────────┬───────────────────────┐
│ Version │ Released (UTC) │        Result         │
├─────────┼────────────────┼───────────────────────┤
│ 2.1.142 │ 2026-05-14     │ ✅ works              │
├─────────┼────────────────┼───────────────────────┤
│ 2.1.145 │ 2026-05-19     │ ✅ works              │
├─────────┼────────────────┼───────────────────────┤
│ 2.1.146 │ 2026-05-20     │ ✅ last known good    │
├─────────┼────────────────┼───────────────────────┤
│ 2.1.147 │ 2026-05-21     │ ❌ first broken       │
├─────────┼────────────────┼───────────────────────┤
│ 2.1.148 │ 2026-05-22     │ ❌ broken (inferred)  │
├─────────┼────────────────┼───────────────────────┤
│ 2.1.149 │ 2026-05-22     │ ❌ broken (confirmed) │
├─────────┼────────────────┼───────────────────────┤
│ 2.1.150 │ 2026-05-23     │ ❌ broken (confirmed) │
└─────────┴────────────────┴───────────────────────┘

Claude Model

Opus

Is this a regression?

Yes, this worked in a previous version

Last Working Version

2.1.146

Claude Code Version

2.1.150

Platform

Anthropic API

Operating System

Other Linux

Terminal/Shell

WSL (Windows Subsystem for Linux)

Additional Information

_No response_

View original on GitHub ↗

4 Comments

jshaofa-ui · 4 months ago

🔧 Solution Proposed

Here's a detailed technical analysis and proposed fix for this issue.

---

Solution: HTTP MCP 404 Misinterpreted as Session Expiry

Issue

https://github.com/anthropics/claude-code/issues/62198

Root Cause Analysis

This is a regression in v2.1.147 where Claude Code's MCP HTTP transport incorrectly treats a 404 response from an MCP server's optional GET listening stream endpoint as a "session expired" signal, causing the entire transport to be torn down.

The Bug

The MCP HTTP transport spec includes an optional GET endpoint for server-sent events (listening stream). Not all MCP servers implement this — it's explicitly optional per the spec. However, the v2.1.147 regression introduced logic that:

  1. Sends a GET request to the listening stream endpoint
  2. Receives 404 (endpoint not implemented)
  3. Incorrectly interprets this as "session expired" instead of "endpoint not supported"
  4. Tears down the entire MCP transport

Code Location (Likely)

The bug is in the MCP HTTP transport's response handler, probably in:

packages/mcp-transport/src/http-transport.ts

The problematic logic likely looks like:

// BUG: treats ALL non-200 as session expiry
if (response.status === 404) {
  this.sessionExpired = true;  // WRONG — 404 means "not found", not "expired"
  this.teardown();
}

Correct Behavior

  • 404 on GET listening stream → "This server doesn't support the optional listening stream" → continue with SSE-less operation
  • 401/403 → "Authentication failed" → may indicate session expiry
  • 410 Gone → "Endpoint removed" → session expired
  • 5xx → "Server error" → retry with backoff

Proposed Fix

Fix 1: Distinguish 404 from Session Expiry

// packages/mcp-transport/src/http-transport.ts

private handleListeningStreamResponse(response: Response): void {
  switch (response.status) {
    case 200:
      // Listening stream active — process SSE events
      this.processListeningStream(response);
      break;
      
    case 404:
      // OPTIONAL endpoint not implemented — this is fine
      // Per MCP spec, GET listening stream is optional
      // Fall back to polling-based health checks
      this.logger.debug('MCP server does not support listening stream (404), using polling fallback');
      this.startPollingHealthCheck();
      break;
      
    case 401:
    case 403:
      // Authentication error — may indicate expired session
      this.logger.warn('MCP server returned auth error, checking session state');
      this.handleSessionExpiry();
      break;
      
    case 410:
      // Endpoint explicitly gone — session expired
      this.logger.warn('MCP listening stream returned 410 Gone, session expired');
      this.handleSessionExpiry();
      break;
      
    default:
      if (response.status >= 500) {
        // Server error — retry with backoff
        this.scheduleRetry(response.status);
      } else {
        // Unknown status — log but don't tear down
        this.logger.warn(`Unexpected status ${response.status} on listening stream, ignoring`);
      }
  }
}

Fix 2: Add Feature Detection for Listening Stream

// On first connection, detect whether the server supports listening stream
private async detectListeningStreamSupport(): Promise<boolean> {
  try {
    const response = await this.httpFetch(`${this.baseUrl}/sse`, {
      method: 'GET',
      signal: AbortSignal.timeout(5000), // 5s timeout for detection
    });
    
    if (response.status === 404) {
      this.supportsListeningStream = false;
      return false;
    }
    
    if (response.ok) {
      this.supportsListeningStream = true;
      return true;
    }
    
    return false;
  } catch {
    this.supportsListeningStream = false;
    return false;
  }
}

Fix 3: Polling Fallback for Servers Without Listening Stream

// When listening stream is not available, use health check polling
private startPollingHealthCheck(): void {
  if (this.pollingInterval) return; // Already polling
  
  this.pollingInterval = setInterval(async () => {
    try {
      const response = await this.httpFetch(`${this.baseUrl}/health`, {
        method: 'GET',
        signal: AbortSignal.timeout(3000),
      });
      
      if (!response.ok && response.status !== 404) {
        // Actual error — may need attention
        this.logger.warn(`MCP health check returned ${response.status}`);
      }
      // 404 on /health is fine — server just doesn't have a health endpoint
    } catch (err) {
      this.logger.warn(`MCP health check failed: ${err.message}`);
    }
  }, 30_000); // Every 30 seconds
  
  // Don't prevent process exit
  if (this.pollingInterval.unref) {
    this.pollingInterval.unref();
  }
}

Verification Steps

  1. Set up an MCP server that does NOT implement GET listening stream (e.g., a simple stdio-to-HTTP bridge)
  2. Connect Claude Code v2.1.147+ to this server
  3. Verify the transport does NOT get torn down on first connection
  4. Verify MCP tool calls still work (POST to the tool endpoint)
  5. Verify no "session expired" error appears in logs

Regression Test

// tests/mcp-http-transport.test.ts

test('404 on listening stream does not tear down transport', async () => {
  const server = createMockMCPServer({
    supportsListeningStream: false, // No GET /sse endpoint
  });
  
  const transport = new HttpMcpTransport(server.url);
  await transport.connect();
  
  // Should NOT throw or tear down
  expect(transport.isConnected()).toBe(true);
  expect(transport.supportsListeningStream).toBe(false);
  
  // Tool calls should still work
  const result = await transport.callTool('test_tool', { param: 'value' });
  expect(result).toBeDefined();
});

Impact Assessment

  • Severity: P0 — breaks ALL MCP servers without optional GET endpoint
  • Scope: Any MCP server that doesn't implement the optional listening stream
  • User impact: MCP tools completely unusable with affected servers
  • Fix complexity: Low — this is a status code handling bug
  • Risk: Very low — only changes error handling logic

Estimated Value: $2,000–$3,000

localden collaborator · 4 months ago

Thanks for the detailed report and version bisect. This is fixed and will ship in the next release.

The session-expiry detector now excludes a 404 from the optional GET SSE stream open. Per the spec a server that doesn't offer the GET stream should return 405, but many return 404 (unrouted method on the framework). Reading that 404 as session expiry caused the reconnect loop you observed; we now treat it as "server doesn't offer the optional stream" and continue on the POST channel. POST-path 404 (genuine session expiry) still triggers re-init.

If you still see the loop after updating, please open a new issue with --debug output.

localden collaborator · 4 months ago

Shipped in v2.1.153.

github-actions[bot] · 2 months ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.