feat(mcp): add SSE transport fallback and keepalive for HTTP MCP servers#3976
feat(mcp): add SSE transport fallback and keepalive for HTTP MCP servers#3976airudotsh wants to merge 2 commits into
Conversation
Some MCP servers (e.g. Supermemory V4 on Cloudflare Workers) use the legacy SSE protocol instead of Streamable HTTP. When the HTTP client attempts a Streamable HTTP connection to such servers, it fails with "Session terminated" or similar errors, making the MCP server unusable. Additionally, SSE-based servers may close idle connections after a few minutes of silence, causing tool calls to fail unpredictably. Changes: - Add SSE transport detection (`_MCP_SSE_AVAILABLE`) at import time - Try Streamable HTTP first; on failure, fall back to SSE transport - Add SSE keepalive coroutine that sends periodic pings (default: 60s) to prevent idle disconnect - Update error message to mention both transport options
Combines the best of both approaches: - Explicit SSE detection via transport: sse config or /sse URL path (based on work by @amiller in NousResearch#5981) - Automatic SSE fallback when Streamable HTTP fails (original approach) - SSE keepalive ping to prevent idle disconnect - OAuth 2.1 PKCE support for SSE connections - Transport type reporting in get_mcp_status() (sse/http/stdio) - 12 new tests for SSE detection and status reporting Routing logic in run(): 1. If _is_sse() → connect directly via SSE (skip HTTP) 2. If plain HTTP → try Streamable HTTP, fallback to SSE on failure This covers both use cases: servers that advertise SSE via /sse paths AND servers where SSE is only discovered after HTTP connection fails.
There was a problem hiding this comment.
Pull request overview
Adds compatibility for MCP servers that only support legacy SSE by introducing SSE transport support (explicit and as a fallback) plus an SSE keepalive loop to reduce idle disconnects.
Changes:
- Add module-level SSE availability detection and updated ImportError messaging for HTTP transports.
- Add SSE connection path (
_run_sse/_run_http_sse) with periodic keepalive pinging. - Extend status reporting and add tests around SSE transport detection and status output.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| tools/mcp_tool.py | Adds SSE transport support, Streamable HTTP→SSE fallback logic, keepalive task, and status transport labeling. |
| tests/tools/test_mcp_tool.py | Updates existing HTTP-unavailable test and adds new tests for SSE detection and status transport reporting. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # If explicitly SSE (config or URL path), go straight to SSE. | ||
| # Otherwise try Streamable HTTP with SSE fallback. | ||
| if self._is_sse(): | ||
| await self._run_sse(config) | ||
| else: | ||
| await self._run_http(config) |
There was a problem hiding this comment.
MCPServerTask._is_sse() is referenced here, but no such method is defined on the class. This will raise AttributeError at runtime (and will also break the newly added tests). Add an _is_sse() helper (e.g., check config.get('transport') == 'sse' or whether the parsed URL path ends with /sse, handling trailing slashes and query params) and keep the behavior consistent with the tests.
| await self._run_http_streamable( | ||
| url, headers, connect_timeout, _oauth_auth, sampling_kwargs | ||
| ) | ||
| return |
There was a problem hiding this comment.
The broad except Exception here will also catch asyncio.CancelledError and then proceed into the SSE fallback path, which can interfere with task cancellation/shutdown. Handle asyncio.CancelledError explicitly (re-raise) before the generic exception handler so cancellation propagates correctly.
| return | |
| return | |
| except asyncio.CancelledError: | |
| raise |
| # Try Streamable HTTP first | ||
| if _MCP_HTTP_AVAILABLE: | ||
| try: | ||
| await self._run_http_streamable( | ||
| url, headers, connect_timeout, _oauth_auth, sampling_kwargs |
There was a problem hiding this comment.
New behavior: Streamable HTTP now falls back to SSE on failure, but there are no tests asserting (1) _run_http calls the SSE path when Streamable HTTP raises (e.g., McpError('Session terminated')) and (2) the original exception is raised when SSE is unavailable. Adding focused unit tests around this branching will help prevent regressions.
| # Determine transport type: sse, http, or stdio | ||
| if "url" in cfg: | ||
| _tmp = MCPServerTask(name) | ||
| _tmp._config = cfg | ||
| transport = "sse" if _tmp._is_sse() else "http" |
There was a problem hiding this comment.
get_mcp_status() instantiates MCPServerTask just to detect SSE vs HTTP, which pulls in asyncio primitives and duplicates runtime transport detection logic. Consider extracting the SSE detection into a small pure helper (or @staticmethod) that operates directly on the config dict/URL so status rendering doesn’t need to construct a full server task.
The SSE read timeout was set to the tool timeout (60s), causing httpcore.ReadTimeout after ~60s of silence. SSE servers like Router Teamwork and Supermemory close idle connections, resulting in ClosedResourceError on subsequent tool calls. Changes: - Add _sse_keepalive() coroutine: sends session.send_ping() every 60s to keep the SSE stream alive (adapted from @airouz in NousResearch#3976) - Bump sse_read_timeout from tool_timeout to 300s (5 min safety net) - Add OAuth 2.1 PKCE support to _run_sse (matching _run_http) - Cancel keepalive cleanly on shutdown with try/finally - Log transport as 'SSE' instead of 'HTTP' in _discover_and_register_server Verified: SSE connection stays alive for 90+ seconds of idle with keepalive. Previously died at ~60s.
Summary
Adds SSE transport fallback and keepalive for HTTP-based MCP servers.
Problem
Some MCP servers (e.g., Supermemory V4 on Cloudflare Workers) use the legacy SSE protocol instead of Streamable HTTP. When Hermes attempts a Streamable HTTP connection, it fails with "Session terminated" — making these servers completely unusable.
Additionally, SSE servers may close idle connections after a few minutes of silence, causing intermittent tool call failures that are hard to debug.
Solution
SSE fallback: When Streamable HTTP fails, automatically falls back to the legacy SSE transport (
sse_client). The import check (_MCP_SSE_AVAILABLE) is done at module load time with a graceful fallback.SSE keepalive: A background coroutine sends periodic pings (default: 60s interval) to keep the SSE connection alive. Prevents servers from closing idle connections.
Updated error messages: When neither transport is available, the error message now mentions both options instead of only Streamable HTTP.
Testing
py_compilepasses on both modified files