feat: add SSE keepalive messages for connection health

Add configurable SSEKeepAlive interval (default: 15s) that sends SSE
comment lines (`:keepalive`) to maintain connection health.

Benefits:
- Keeps connections alive through proxies/load balancers that timeout
  idle connections
- Detects stale connections earlier (write failures terminate the
  handler)
- Standard SSE pattern - comments are ignored by compliant clients

Configuration:
- SSEKeepAlive > 0: send keepalives at specified interval
- SSEKeepAlive = 0: use default (15s)
- SSEKeepAlive < 0: disable keepalives

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 22:10:58 +01:00
parent 684baf63da
commit 08f8b2cd83
2 changed files with 101 additions and 1 deletions

View File

@@ -409,6 +409,69 @@ func TestHTTPTransportSSEStream(t *testing.T) {
}
}
func TestHTTPTransportSSEKeepalive(t *testing.T) {
transport, ts := testHTTPTransport(t, HTTPConfig{
SSEKeepAlive: 50 * time.Millisecond, // Short interval for testing
})
session, _ := transport.sessions.Create()
// Start SSE stream
req, _ := http.NewRequest("GET", ts.URL+"/mcp", nil)
req.Header.Set("Mcp-Session-Id", session.ID)
req.Header.Set("Accept", "text/event-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("Expected 200, got %d", resp.StatusCode)
}
// Read with timeout - should receive keepalive within 100ms
buf := make([]byte, 256)
done := make(chan struct{})
var readData string
var readErr error
go func() {
n, err := resp.Body.Read(buf)
readData = string(buf[:n])
readErr = err
close(done)
}()
select {
case <-done:
if readErr != nil && readErr.Error() != "EOF" {
t.Fatalf("Read error: %v", readErr)
}
// Should receive SSE comment keepalive
if !strings.Contains(readData, ":keepalive") {
t.Errorf("Expected keepalive comment, got: %q", readData)
}
case <-time.After(200 * time.Millisecond):
t.Error("Timeout waiting for keepalive")
}
}
func TestHTTPTransportSSEKeepaliveDisabled(t *testing.T) {
server := NewServer(nil, log.New(io.Discard, "", 0))
config := HTTPConfig{
SSEKeepAlive: -1, // Explicitly disabled
}
transport := NewHTTPTransport(server, config)
defer transport.sessions.Stop()
// When SSEKeepAlive is negative, it should remain negative (disabled)
if transport.config.SSEKeepAlive != -1 {
t.Errorf("Expected SSEKeepAlive to remain -1 (disabled), got %v", transport.config.SSEKeepAlive)
}
}
func TestHTTPTransportParseError(t *testing.T) {
_, ts := testHTTPTransport(t, HTTPConfig{})
@@ -510,6 +573,9 @@ func TestHTTPTransportDefaultConfig(t *testing.T) {
if transport.config.ReadHeaderTimeout != DefaultReadHeaderTimeout {
t.Errorf("Expected default read header timeout %v, got %v", DefaultReadHeaderTimeout, transport.config.ReadHeaderTimeout)
}
if transport.config.SSEKeepAlive != DefaultSSEKeepAlive {
t.Errorf("Expected default SSE keepalive %v, got %v", DefaultSSEKeepAlive, transport.config.SSEKeepAlive)
}
transport.sessions.Stop()
}