From f6eb4689ca483e04cb991e85f8287f7798f4d3e9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 1 Sep 2026 13:03:29 -0700 Subject: [PATCH 1/6] feat: enable auto-reconnect for SseServer --- cecli/mcp/server.py | 35 ++++++++--------------------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/cecli/mcp/server.py b/cecli/mcp/server.py index 79eda91176f..9d596af8653 100644 --- a/cecli/mcp/server.py +++ b/cecli/mcp/server.py @@ -468,35 +468,16 @@ def _create_transport(self, url, http_client): return streamable_http_client(url, http_client=http_client) -class SseServer(McpServer): - """SSE (Server-Sent Events) MCP server using mcp.client.sse_client.""" +class SseServer(HttpBasedMcpServer): + """SSE (Server-Sent Events) MCP server using mcp.client.sse_client. - async def connect(self): - current_loop = asyncio.get_running_loop() - if self.session is not None: - if self._connection_loop is current_loop: - logging.info(f"Using existing session for SSE MCP server: {self.name}") - return self.session - logging.info(f"Reconnecting SSE MCP server {self.name} (event loop changed)") - await self.disconnect() + Inherits keepalive pings and exponential-backoff auto-reconnect from + HttpBasedMcpServer so dropped SSE connections recover automatically. + """ - logging.info(f"Establishing new connection to SSE MCP server: {self.name}") - try: - url = self.config.get("url") - headers = self.config.get("headers", {}) - sse_transport = await self.exit_stack.enter_async_context( - sse_client(url, headers=headers) - ) - read, write = sse_transport - session = await self.exit_stack.enter_async_context(ClientSession(read, write)) - await session.initialize() - self.session = session - self._connection_loop = current_loop - return session - except Exception as e: - logging.error(f"Error initializing SSE server {self.name}: {e}") - await self.disconnect() - raise + def _create_transport(self, url, http_client): + """Create the SSE transport. The shared http_client is used for keepalive pings.""" + return sse_client(url, headers=self.config.get("headers", {})) class LocalServer(McpServer): From ce63af07cd9cc4245d62645b438296d58c5829e6 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 1 Sep 2026 13:48:09 -0700 Subject: [PATCH 2/6] fix: Correct SseServer inheritance for keepalive and reconnect --- cecli/mcp/server.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/cecli/mcp/server.py b/cecli/mcp/server.py index cba910b3adf..29654454732 100644 --- a/cecli/mcp/server.py +++ b/cecli/mcp/server.py @@ -593,17 +593,13 @@ def _create_transport(self, url, http_client): class SseServer(HttpBasedMcpServer): """SSE (Server-Sent Events) MCP server using mcp.client.sse_client. - async def _open_session(self): - url = self.config.get("url") - headers = self.config.get("headers", {}) - - sse_transport = await self.exit_stack.enter_async_context(sse_client(url, headers=headers)) - read, write = sse_transport - session = await self.exit_stack.enter_async_context(ClientSession(read, write)) - await session.initialize() - self.session = session + Inherits keepalive pings and exponential-backoff auto-reconnect from + HttpBasedMcpServer so dropped SSE connections recover automatically. + """ - return session + def _create_transport(self, url, http_client): + """Create the SSE transport. The shared http_client is used for keepalive pings.""" + return sse_client(url, headers=self.config.get("headers", {})) class LocalServer(McpServer): From b2a6eb8b21a3ffca315711f0a25bacdd440e83b4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 1 Sep 2026 14:17:39 -0700 Subject: [PATCH 3/6] build: Exclude markdown files from flake8 linting --- .flake8 | 1 + 1 file changed, 1 insertion(+) diff --git a/.flake8 b/.flake8 index c923419b923..e71bf279f2d 100644 --- a/.flake8 +++ b/.flake8 @@ -1,3 +1,4 @@ [flake8] ignore = E203,W503 max-line-length = 120 +exclude = .*\.(md|markdown)$ From 11e9213ea2a2642861bfdb4bc779f133eae9325f Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 1 Sep 2026 15:39:44 -0700 Subject: [PATCH 4/6] test: implement Section 11 test cases for CLI-59 --- tests/mcp/test_section11_sse_behavior.py | 201 +++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 tests/mcp/test_section11_sse_behavior.py diff --git a/tests/mcp/test_section11_sse_behavior.py b/tests/mcp/test_section11_sse_behavior.py new file mode 100644 index 00000000000..3fff932930a --- /dev/null +++ b/tests/mcp/test_section11_sse_behavior.py @@ -0,0 +1,201 @@ +"""Section 11 test cases for CLI-59: SSE connection behavior verification. + +These tests verify the higher-level SSE connection behavior requirements: + +- 11.1: Connection stability under inactivity (>= 30 min idle, no disconnects). +- 11.2: Reconnection after network drop with exponential backoff + (1s -> 2s -> 4s, capped at 300s, +/-20% jitter). + +Because a literal 30-minute idle window is impractical for a unit test, the +stability test uses the shortened 1-second keepalive interval (enabled by the +conftest ``MIN_KEEPALIVE_INTERVAL = 1`` override) and runs through many +keepalive cycles, asserting the connection never leaves CONNECTED and never +accumulates failed pings. This exercises the same code path a real 30-minute +idle period would, just compressed in time. +""" + +import asyncio +from unittest.mock import patch + +import pytest + +from cecli.mcp.server import ConnectionState +from tests.mcp.conftest import ServerStateInspector + + +class TestSection11ConnectionStability: + """11.1: Connection stability under inactivity (no disconnects).""" + + @pytest.mark.asyncio + async def test_connection_stable_over_extended_idle_period( + self, http_based_server, running_mock_server + ): + """Verify the connection remains CONNECTED with zero failed pings over + an extended idle period (many keepalive cycles, simulating 30+ min). + """ + inspector = ServerStateInspector() + server = http_based_server + + await server.connect() + await asyncio.sleep(0.1) # Allow keepalive task to start + + assert inspector.get_state(server) == ConnectionState.CONNECTED + assert inspector.is_keepalive_running(server) + + # Run through many keepalive intervals (1s each). 30 intervals + # approximates a 30-minute idle window at a 60s production interval. + for _ in range(30): + await asyncio.sleep(1.0) + # The connection must never leave CONNECTED during idle. + assert inspector.get_state(server) == ConnectionState.CONNECTED + + # No failed pings accumulated and the keepalive task is still alive. + assert inspector.get_failed_pings(server) == 0 + assert inspector.is_keepalive_running(server) + + await server.disconnect() + + @pytest.mark.asyncio + async def test_no_disconnect_during_idle_with_healthy_server( + self, http_based_server, running_mock_server + ): + """Verify a healthy server never triggers a disconnect during idle.""" + inspector = ServerStateInspector() + server = http_based_server + + await server.connect() + await asyncio.sleep(0.1) + + # Track that the state never becomes DISCONNECTED or UNHEALTHY. + observed_states = set() + for _ in range(15): + await asyncio.sleep(1.0) + observed_states.add(inspector.get_state(server)) + + assert ConnectionState.DISCONNECTED not in observed_states + assert ConnectionState.UNHEALTHY not in observed_states + assert ConnectionState.CONNECTED in observed_states + + await server.disconnect() + + +class TestSection11ReconnectionBackoff: + """11.2: Reconnection after network drop with exponential backoff.""" + + @pytest.mark.asyncio + async def test_exponential_backoff_sequence_1s_2s_4s( + self, http_based_server, running_mock_server + ): + """Verify reconnect delays follow 1s -> 2s -> 4s (doubling) with + +/-20% jitter, and never exceed the 300s cap. + """ + server = http_based_server + server.config["keepalive_interval"] = 1 + running_mock_server.set_status(500) + + reconnect_delays = [] + original_sleep = asyncio.sleep + + async def mock_sleep(duration): + # Record only the reconnect backoff sleeps (keepalive sleeps are + # ~1s +/-10%; reconnect sleeps are >= 0.8s and grow exponentially). + reconnect_delays.append(duration) + if duration > 0.5: + await original_sleep(0) # Yield without actually sleeping + return + + with patch("asyncio.sleep", side_effect=mock_sleep): + await server.connect() + for _ in range(400): + await original_sleep(0) + + await server.disconnect() + + # Reconnect delays are the sleeps >= 0.8s (keepalive jitter can dip to + # 0.9s, so use 0.8 as the floor to separate reconnect from keepalive). + delays = [d for d in reconnect_delays if d >= 0.8] + + assert len(delays) >= 3, f"Expected >= 3 reconnect delays, got {delays}" + + # Verify exponential doubling: each delay is ~2x the previous, within + # the +/-20% jitter tolerance. + for i in range(1, len(delays)): + prev = delays[i - 1] + curr = delays[i] + # Expected doubling with 20% jitter on both sides. + lower = prev * 2 * 0.8 + upper = prev * 2 * 1.2 + assert lower <= curr <= upper, ( + f"Delay {curr} at index {i} is not ~2x previous {prev} " + f"(expected range {lower}-{upper})" + ) + + # Verify the cap: no delay exceeds 300s. + for delay in delays: + assert delay <= 300, f"Delay {delay} exceeds max_delay of 300" + + @pytest.mark.asyncio + async def test_backoff_capped_at_300_seconds(self, http_based_server, running_mock_server): + """Verify reconnect delays are capped at 300s once the backoff grows + large enough (1, 2, 4, 8, 16, 32, 64, 128, 256, 300, 300, ...). + """ + server = http_based_server + server.config["keepalive_interval"] = 1 + running_mock_server.set_status(500) + + reconnect_delays = [] + original_sleep = asyncio.sleep + + async def mock_sleep(duration): + reconnect_delays.append(duration) + if duration > 0.5: + await original_sleep(0) + return + + with patch("asyncio.sleep", side_effect=mock_sleep): + await server.connect() + for _ in range(2000): + await original_sleep(0) + + await server.disconnect() + + delays = [d for d in reconnect_delays if d >= 0.8] + + # The backoff sequence should eventually reach and stay at the 300s cap. + assert any( + d >= 240 for d in delays + ), f"Expected backoff to reach the 300s cap, got delays {delays}" + + # No delay may exceed the cap. + for delay in delays: + assert delay <= 300, f"Delay {delay} exceeds max_delay of 300" + + @pytest.mark.asyncio + async def test_backoff_jitter_within_20_percent(self, http_based_server, running_mock_server): + """Verify reconnect delays incorporate +/-20% jitter (not all identical).""" + server = http_based_server + server.config["keepalive_interval"] = 1 + running_mock_server.set_status(500) + + reconnect_delays = [] + original_sleep = asyncio.sleep + + async def mock_sleep(duration): + reconnect_delays.append(duration) + if duration > 0.5: + await original_sleep(0) + return + + with patch("asyncio.sleep", side_effect=mock_sleep): + await server.connect() + for _ in range(400): + await original_sleep(0) + + await server.disconnect() + + delays = [d for d in reconnect_delays if d >= 0.8] + + assert len(delays) >= 2, f"Expected >= 2 reconnect delays, got {delays}" + + # Jitter means delays should not all be identical. + assert len(set(delays)) > 1, "Reconnect delays should vary due to jitter" From c680f41a57248606e8fdcf52e7cc61229535247a Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 1 Sep 2026 16:09:11 -0700 Subject: [PATCH 5/6] docs: add SSE keep-alive and reconnection docs to README --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index aaf57773b5c..4b71af7a9b4 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,17 @@ Use the tool installation so cecli doesn't interfere with your development envir [Additional Documentation](https://cecli.dev/) +## SSE Keep-Alive & Reconnection + +To maintain stable SSE connections, the `SseServer` implements a keep-alive mechanism and automatic reconnection strategy. + +### Configuration +The server reads the following settings from the MCP server configuration: + +- **`keepalive_interval`**: (int) Interval in seconds for pinging the client (range: 5–300). If unset, heartbeats are disabled. +- **Pings**: Sent as `OPTIONS` requests with +/- 10% jitter. +- **Reconnection**: After three failed pings, the connection is marked `DISCONNECTED` and initiates an exponential backoff strategy (1s, 2s, 4s..., capped at 300s, +/- 20% jitter). + ## Project Roadmap/Goals 1. **Base Asynchronicity (cecli coroutine-experiment branch)** From 53cb9d04305d46622721a4a5619681ecd786c3cd Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 1 Sep 2026 16:25:09 -0700 Subject: [PATCH 6/6] fix: update flake8 exclude pattern to support markdown files --- .flake8 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.flake8 b/.flake8 index e71bf279f2d..87a59981f49 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] ignore = E203,W503 max-line-length = 120 -exclude = .*\.(md|markdown)$ +exclude = *.md,*.markdown