Skip to content

Commit 1eee765

Browse files
committed
feat: Add configurable SSE keepalive events
- Add SSE_KEEPALIVE_ENABLED and SSE_KEEPALIVE_INTERVAL environment variables - Update sse_transport.py to respect keepalive configuration settings - Update translate.py to use configurable keepalive defaults - Add comprehensive tests for keepalive configuration scenarios - Update documentation with configuration options and troubleshooting - Fixes issue #689 by allowing users to disable keepalive events for incompatible clients - Supports infrastructure-specific intervals (30s default, 60s AWS ALB, 240s Azure) Signed-off-by: Mihai Criveti <[email protected]>
1 parent a4936bb commit 1eee765

File tree

7 files changed

+140
-24
lines changed

7 files changed

+140
-24
lines changed

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,16 @@ WEBSOCKET_PING_INTERVAL=30
187187
# SSE client retry timeout (milliseconds)
188188
SSE_RETRY_TIMEOUT=5000
189189

190+
# Enable SSE keepalive events (true/false)
191+
# Set to false to disable keepalive events completely
192+
# Note: Disabling may cause timeouts with proxies/load balancers
193+
SSE_KEEPALIVE_ENABLED=true
194+
195+
# SSE keepalive interval (seconds)
196+
# How often to send keepalive events during idle periods
197+
# Common values: 30 (default), 60 (AWS ALB), 120, 240 (Azure)
198+
SSE_KEEPALIVE_INTERVAL=30
199+
190200

191201
#####################################
192202
# Streamabe HTTP Transport Configuration

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,3 +215,6 @@ pytest -m "not slow"
215215

216216
make autoflake isort black pre-commit
217217
make doctest test smoketest lint-web flake8 pylint
218+
219+
# Rules
220+
- When using git commit always add a -s to sign commits

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ It currently supports:
126126

127127
* Federation across multiple MCP and REST services
128128
* Virtualization of legacy APIs as MCP-compliant tools and servers
129-
* Transport over HTTP, JSON-RPC, WebSocket, SSE, stdio and streamable-HTTP
129+
* Transport over HTTP, JSON-RPC, WebSocket, SSE (with configurable keepalive), stdio and streamable-HTTP
130130
* An Admin UI for real-time management and configuration
131131
* Built-in auth, observability, retries, and rate-limiting
132132
* Scalable deployments via Docker or PyPI, Redis-backed caching, and multi-cluster federation
@@ -1029,9 +1029,13 @@ You can get started by copying the provided [.env.example](.env.example) to `.en
10291029
| `TRANSPORT_TYPE` | Enabled transports | `all` | `http`,`ws`,`sse`,`stdio`,`all` |
10301030
| `WEBSOCKET_PING_INTERVAL` | WebSocket ping (secs) | `30` | int > 0 |
10311031
| `SSE_RETRY_TIMEOUT` | SSE retry timeout (ms) | `5000` | int > 0 |
1032+
| `SSE_KEEPALIVE_ENABLED` | Enable SSE keepalive events | `true` | bool |
1033+
| `SSE_KEEPALIVE_INTERVAL` | SSE keepalive interval (secs) | `30` | int > 0 |
10321034
| `USE_STATEFUL_SESSIONS` | streamable http config | `false` | bool |
10331035
| `JSON_RESPONSE_ENABLED` | json/sse streams (streamable http) | `true` | bool |
10341036

1037+
> **💡 SSE Keepalive Events**: The gateway sends periodic keepalive events to prevent connection timeouts with proxies and load balancers. Disable with `SSE_KEEPALIVE_ENABLED=false` if your client doesn't handle unknown event types. Common intervals: 30s (default), 60s (AWS ALB), 240s (Azure).
1038+
10351039
### Federation
10361040

10371041
| Setting | Description | Default | Options |
@@ -1112,7 +1116,13 @@ MCP Gateway uses Alembic for database migrations. Common commands:
11121116

11131117
#### Troubleshooting
11141118

1115-
If you see "No 'script_location' key found", ensure you're running from the project root directory.
1119+
**Common Issues:**
1120+
1121+
- **"No 'script_location' key found"**: Ensure you're running from the project root directory.
1122+
1123+
- **"Unknown SSE event: keepalive" warnings**: Some MCP clients don't recognize keepalive events. These warnings are harmless and don't affect functionality. To disable: `SSE_KEEPALIVE_ENABLED=false`
1124+
1125+
- **Connection timeouts with proxies/load balancers**: If experiencing timeouts, adjust keepalive interval to match your infrastructure: `SSE_KEEPALIVE_INTERVAL=60` (AWS ALB) or `240` (Azure).
11161126

11171127
### Development
11181128

mcpgateway/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,8 @@ def _parse_allowed_origins(cls, v):
197197
transport_type: str = "all" # http, ws, sse, all
198198
websocket_ping_interval: int = 30 # seconds
199199
sse_retry_timeout: int = 5000 # milliseconds
200+
sse_keepalive_enabled: bool = True # Enable SSE keepalive events
201+
sse_keepalive_interval: int = 30 # seconds between keepalive events
200202

201203
# Federation
202204
federation_enabled: bool = True

mcpgateway/translate.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,20 @@
7878
httpx = None # type: ignore[assignment]
7979

8080
LOGGER = logging.getLogger("mcpgateway.translate")
81-
KEEP_ALIVE_INTERVAL = 30 # seconds - matches the reference implementation
81+
82+
# Import settings for default keepalive interval
83+
try:
84+
# First-Party
85+
from mcpgateway.config import settings
86+
87+
DEFAULT_KEEP_ALIVE_INTERVAL = settings.sse_keepalive_interval
88+
DEFAULT_KEEPALIVE_ENABLED = settings.sse_keepalive_enabled
89+
except ImportError:
90+
# Fallback if config not available
91+
DEFAULT_KEEP_ALIVE_INTERVAL = 30
92+
DEFAULT_KEEPALIVE_ENABLED = True
93+
94+
KEEP_ALIVE_INTERVAL = DEFAULT_KEEP_ALIVE_INTERVAL # seconds - from config or fallback to 30
8295
__all__ = ["main"] # for console-script entry-point
8396

8497

@@ -571,23 +584,26 @@ async def event_gen() -> AsyncIterator[Dict[str, Any]]:
571584
"retry": int(keep_alive * 1000),
572585
}
573586

574-
# 2️⃣ Immediate keepalive so clients know the stream is alive
575-
yield {"event": "keepalive", "data": "{}", "retry": keep_alive * 1000}
587+
# 2️⃣ Immediate keepalive so clients know the stream is alive (if enabled in config)
588+
if DEFAULT_KEEPALIVE_ENABLED:
589+
yield {"event": "keepalive", "data": "{}", "retry": keep_alive * 1000}
576590

577591
try:
578592
while True:
579593
if await request.is_disconnected():
580594
break
581595

582596
try:
583-
msg = await asyncio.wait_for(queue.get(), keep_alive)
597+
timeout = keep_alive if DEFAULT_KEEPALIVE_ENABLED else None
598+
msg = await asyncio.wait_for(queue.get(), timeout)
584599
yield {"event": "message", "data": msg.rstrip()}
585600
except asyncio.TimeoutError:
586-
yield {
587-
"event": "keepalive",
588-
"data": "{}",
589-
"retry": keep_alive * 1000,
590-
}
601+
if DEFAULT_KEEPALIVE_ENABLED:
602+
yield {
603+
"event": "keepalive",
604+
"data": "{}",
605+
"retry": keep_alive * 1000,
606+
}
591607
finally:
592608
pubsub.unsubscribe(queue)
593609

mcpgateway/transports/sse_transport.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -346,20 +346,22 @@ async def event_generator():
346346
"retry": settings.sse_retry_timeout,
347347
}
348348

349-
# Send keepalive immediately to help establish connection
350-
yield {
351-
"event": "keepalive",
352-
"data": "{}",
353-
"retry": settings.sse_retry_timeout,
354-
}
349+
# Send keepalive immediately to help establish connection (if enabled)
350+
if settings.sse_keepalive_enabled:
351+
yield {
352+
"event": "keepalive",
353+
"data": "{}",
354+
"retry": settings.sse_retry_timeout,
355+
}
355356

356357
try:
357358
while not self._client_gone.is_set():
358359
try:
359360
# Wait for messages with a timeout for keepalives
361+
timeout = settings.sse_keepalive_interval if settings.sse_keepalive_enabled else None
360362
message = await asyncio.wait_for(
361363
self._message_queue.get(),
362-
timeout=30.0, # 30 second timeout for keepalives (some tools require more timeout for execution)
364+
timeout=timeout, # Configurable timeout for keepalives (some tools require more timeout for execution)
363365
)
364366

365367
data = json.dumps(message, default=lambda obj: (obj.strftime("%Y-%m-%d %H:%M:%S") if isinstance(obj, datetime) else TypeError("Type not serializable")))
@@ -373,12 +375,13 @@ async def event_generator():
373375
"retry": settings.sse_retry_timeout,
374376
}
375377
except asyncio.TimeoutError:
376-
# Send keepalive on timeout
377-
yield {
378-
"event": "keepalive",
379-
"data": "{}",
380-
"retry": settings.sse_retry_timeout,
381-
}
378+
# Send keepalive on timeout (if enabled)
379+
if settings.sse_keepalive_enabled:
380+
yield {
381+
"event": "keepalive",
382+
"data": "{}",
383+
"retry": settings.sse_retry_timeout,
384+
}
382385
except Exception as e:
383386
logger.error(f"Error processing SSE message: {e}")
384387
yield {

tests/unit/mcpgateway/transports/test_sse_transport.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,75 @@ async def test_event_generator(self, sse_transport, mock_request):
228228

229229
# Cancel the generator to clean up
230230
sse_transport._client_gone.set()
231+
232+
@pytest.mark.asyncio
233+
async def test_keepalive_disabled(self, sse_transport, mock_request):
234+
"""Test SSE response when keepalive is disabled."""
235+
with patch("mcpgateway.transports.sse_transport.settings") as mock_settings:
236+
mock_settings.sse_keepalive_enabled = False
237+
mock_settings.sse_keepalive_interval = 30
238+
mock_settings.sse_retry_timeout = 5000
239+
240+
await sse_transport.connect()
241+
response = await sse_transport.create_sse_response(mock_request)
242+
generator = response.body_iterator
243+
244+
# First event should be endpoint
245+
event = await generator.__anext__()
246+
assert event["event"] == "endpoint"
247+
248+
# No immediate keepalive should be sent
249+
# Queue a test message
250+
test_message = {"jsonrpc": "2.0", "result": "test", "id": 1}
251+
await sse_transport._message_queue.put(test_message)
252+
253+
# Next event should be the message (no keepalive)
254+
event = await generator.__anext__()
255+
assert event["event"] == "message"
256+
257+
sse_transport._client_gone.set()
258+
259+
@pytest.mark.asyncio
260+
async def test_keepalive_custom_interval(self, sse_transport, mock_request):
261+
"""Test SSE response with custom keepalive interval."""
262+
with patch("mcpgateway.transports.sse_transport.settings") as mock_settings:
263+
mock_settings.sse_keepalive_enabled = True
264+
mock_settings.sse_keepalive_interval = 60 # Custom interval
265+
mock_settings.sse_retry_timeout = 5000
266+
267+
await sse_transport.connect()
268+
response = await sse_transport.create_sse_response(mock_request)
269+
generator = response.body_iterator
270+
271+
# First event should be endpoint
272+
event = await generator.__anext__()
273+
assert event["event"] == "endpoint"
274+
275+
# Second event should be immediate keepalive
276+
event = await generator.__anext__()
277+
assert event["event"] == "keepalive"
278+
assert event["data"] == "{}"
279+
280+
sse_transport._client_gone.set()
281+
282+
@pytest.mark.asyncio
283+
async def test_keepalive_timeout_behavior(self, sse_transport, mock_request):
284+
"""Test timeout behavior respects keepalive settings."""
285+
with patch("mcpgateway.transports.sse_transport.settings") as mock_settings:
286+
mock_settings.sse_keepalive_enabled = True
287+
mock_settings.sse_keepalive_interval = 1 # 1 second for quick test
288+
mock_settings.sse_retry_timeout = 5000
289+
290+
await sse_transport.connect()
291+
response = await sse_transport.create_sse_response(mock_request)
292+
generator = response.body_iterator
293+
294+
# Skip endpoint and initial keepalive
295+
await generator.__anext__() # endpoint
296+
await generator.__anext__() # initial keepalive
297+
298+
# Wait for timeout keepalive (should happen after 1 second)
299+
event = await asyncio.wait_for(generator.__anext__(), timeout=2.0)
300+
assert event["event"] == "keepalive"
301+
302+
sse_transport._client_gone.set()

0 commit comments

Comments
 (0)