Skip to content

Commit f985606

Browse files
authored
fix: prevent safeoutputs session expiry during long-running agent tasks (#3079)
MCP sessions (notably `safeoutputs`) expire after ~30 minutes of backend inactivity. When agents run long CPU-bound tasks (ML training, large builds), no MCP calls are made, the server-side session silently expires, and all subsequent calls fail with `session not found` — making results undeliverable. ## Changes ### HTTP backend keepalive (primary fix) - Adds `DefaultHTTPKeepaliveInterval = 25 * time.Minute` — intentionally under the typical 30-min backend timeout - Threads a `keepAlive time.Duration` parameter through `newMCPClient` into the SDK's built-in `ClientOptions.KeepAlive`, which automatically sends periodic `ping` requests to keep the server-side session alive - Stores `keepAliveInterval` on the `Connection` struct so `reconnectSDKTransport` preserves the same keepalive on reconnect - STDIO connections use `keepAlive=0` — process lifecycle manages the session, no pings needed ```go // HTTP connections now use keepalive; STDIO does not client := newMCPClient(logConn, DefaultHTTPKeepaliveInterval) // HTTP client := newMCPClient(logConn, 0) // stdio ``` ### Connection pool idle timeout extension (secondary fix) - `DefaultIdleTimeout`: `30m → 6h` — aligns with the maximum autoloop workflow duration, preventing premature eviction of STDIO backend connections from the session pool during long quiet periods > [!WARNING] > > <details> > <summary>Firewall rules blocked me from connecting to one or more addresses (expand for details)</summary> > > #### I tried to connect to the following addresses, but was blocked by firewall rules: > > - `example.com` > - Triggering command: `/tmp/go-build1955081425/b336/launcher.test /tmp/go-build1955081425/b336/launcher.test -test.testlogfile=/tmp/go-build1955081425/b336/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true 64/src/runtime/c-I AqUKQ403Y de/node/bin/as` (dns block) > - Triggering command: `/tmp/go-build216210123/b264/launcher.test /tmp/go-build216210123/b264/launcher.test -test.testlogfile=/tmp/go-build216210123/b264/testlog.txt -test.paniconexit0 -test.timeout=10m0s 5081�� 5081425/b189/_pkg_.a ache/go/1.25.8/x64/src/regexp/syntax/compile.go x_amd64/vet -p l -lang=go1.25 x_amd64/vet go_.�� 64/src/net SkhS/x3QNW0bkE1zmain 64/pkg/tool/linu-lang=go1.25 -I /tmp/go-build195-buildid -I 64/pkg/tool/linu-dwarf=false` (dns block) > - Triggering command: `/tmp/go-build3185549484/b001/launcher.test /tmp/go-build3185549484/b001/launcher.test -test.testlogfile=/tmp/go-build3185549484/b001/testlog.txt -test.paniconexit0 -test.timeout=10m0s --ve�� 359733afab535821 ache/go/1.25.8/x64/pkg/tool/linujson 4769656/b379/vet.cfg 524e3443524efd17git 5081425/b152/vetshow f33/log.json 841eeb0f198986d61e1/log.json -qE` (dns block) > - `invalid-host-that-does-not-exist-12345.com` > - Triggering command: `/tmp/go-build1955081425/b318/config.test /tmp/go-build1955081425/b318/config.test -test.testlogfile=/tmp/go-build1955081425/b318/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true -c=4 -nolocalimports -importcfg /tmp/go-build1955081425/b286/importcfg -pack /home/REDACTED/work/gh-aw-mcpg/gh-aw-mcpg/internal/oidc/provider.go 64/pkg/tool/linu/tmp/go-build1955081425/b124/vet.cfg --no�� go dedword.go 64/pkg/tool/linux_amd64/compile` (dns block) > - Triggering command: `/tmp/go-build1294769656/b322/config.test /tmp/go-build1294769656/b322/config.test -test.testlogfile=/tmp/go-build1294769656/b322/testlog.txt -test.paniconexit0 -test.timeout=10m0s --ctstate INVALID,NEW -j DROP --gdwarf-5 --64 -o x_amd64/compile` (dns block) > - `nonexistent.local` > - Triggering command: `/tmp/go-build1955081425/b336/launcher.test /tmp/go-build1955081425/b336/launcher.test -test.testlogfile=/tmp/go-build1955081425/b336/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true 64/src/runtime/c-I AqUKQ403Y de/node/bin/as` (dns block) > - Triggering command: `/tmp/go-build216210123/b264/launcher.test /tmp/go-build216210123/b264/launcher.test -test.testlogfile=/tmp/go-build216210123/b264/testlog.txt -test.paniconexit0 -test.timeout=10m0s 5081�� 5081425/b189/_pkg_.a ache/go/1.25.8/x64/src/regexp/syntax/compile.go x_amd64/vet -p l -lang=go1.25 x_amd64/vet go_.�� 64/src/net SkhS/x3QNW0bkE1zmain 64/pkg/tool/linu-lang=go1.25 -I /tmp/go-build195-buildid -I 64/pkg/tool/linu-dwarf=false` (dns block) > - Triggering command: `/tmp/go-build3185549484/b001/launcher.test /tmp/go-build3185549484/b001/launcher.test -test.testlogfile=/tmp/go-build3185549484/b001/testlog.txt -test.paniconexit0 -test.timeout=10m0s --ve�� 359733afab535821 ache/go/1.25.8/x64/pkg/tool/linujson 4769656/b379/vet.cfg 524e3443524efd17git 5081425/b152/vetshow f33/log.json 841eeb0f198986d61e1/log.json -qE` (dns block) > - `slow.example.com` > - Triggering command: `/tmp/go-build1955081425/b336/launcher.test /tmp/go-build1955081425/b336/launcher.test -test.testlogfile=/tmp/go-build1955081425/b336/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true 64/src/runtime/c-I AqUKQ403Y de/node/bin/as` (dns block) > - Triggering command: `/tmp/go-build216210123/b264/launcher.test /tmp/go-build216210123/b264/launcher.test -test.testlogfile=/tmp/go-build216210123/b264/testlog.txt -test.paniconexit0 -test.timeout=10m0s 5081�� 5081425/b189/_pkg_.a ache/go/1.25.8/x64/src/regexp/syntax/compile.go x_amd64/vet -p l -lang=go1.25 x_amd64/vet go_.�� 64/src/net SkhS/x3QNW0bkE1zmain 64/pkg/tool/linu-lang=go1.25 -I /tmp/go-build195-buildid -I 64/pkg/tool/linu-dwarf=false` (dns block) > - Triggering command: `/tmp/go-build3185549484/b001/launcher.test /tmp/go-build3185549484/b001/launcher.test -test.testlogfile=/tmp/go-build3185549484/b001/testlog.txt -test.paniconexit0 -test.timeout=10m0s --ve�� 359733afab535821 ache/go/1.25.8/x64/pkg/tool/linujson 4769656/b379/vet.cfg 524e3443524efd17git 5081425/b152/vetshow f33/log.json 841eeb0f198986d61e1/log.json -qE` (dns block) > - `this-host-does-not-exist-12345.com` > - Triggering command: `/tmp/go-build1955081425/b345/mcp.test /tmp/go-build1955081425/b345/mcp.test -test.testlogfile=/tmp/go-build1955081425/b345/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true --noprofile 64/src/runtime/c-ifaceassert x_amd64/vet` (dns block) > - Triggering command: `/tmp/go-build216210123/b252/mcp.test /tmp/go-build216210123/b252/mcp.test -test.testlogfile=/tmp/go-build216210123/b252/testlog.txt -test.paniconexit0 -test.timeout=10m0s 5081�� internal/chacha8rand ache/go/1.25.8/x64/src/runtime/cgo x_amd64/vet 5081425/b171/ /tmp/go-build195run` (dns block) > > If you need me to access, download, or install something from one of these locations, you can either: > > - Configure [Actions setup steps](https://gh.io/copilot/actions-setup-steps) to set up my environment, which run before the firewall is enabled > - Add the appropriate URLs or hosts to the custom allowlist in this repository's [Copilot coding agent settings](https://github.com/github/gh-aw-mcpg/settings/copilot/coding_agent) (admins only) > > </details>
2 parents 1a09953 + 1039c17 commit f985606

13 files changed

Lines changed: 170 additions & 84 deletions

internal/config/config_core.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,18 @@ import (
2929
"io"
3030
"log"
3131
"os"
32+
"time"
3233

3334
"github.com/BurntSushi/toml"
3435
"github.com/github/gh-aw-mcpg/internal/logger"
3536
)
3637

3738
// Core constants for configuration defaults
3839
const (
39-
DefaultPort = 3000
40-
DefaultStartupTimeout = 60 // seconds
41-
DefaultToolTimeout = 120 // seconds
40+
DefaultPort = 3000
41+
DefaultStartupTimeout = 60 // seconds
42+
DefaultToolTimeout = 120 // seconds
43+
DefaultKeepaliveInterval = 1500 // seconds (25 minutes) — keeps HTTP backend sessions alive
4244
)
4345

4446
// Config represents the internal gateway configuration.
@@ -87,6 +89,13 @@ type GatewayConfig struct {
8789
// ToolTimeout is the maximum time (seconds) to wait for tool execution
8890
ToolTimeout int `toml:"tool_timeout" json:"tool_timeout,omitempty"`
8991

92+
// KeepaliveInterval is the interval (seconds) for sending keepalive pings to HTTP
93+
// backends. This prevents long-running sessions from being expired by the remote
94+
// server's idle timeout (typically 30 minutes). Set to -1 to disable keepalive
95+
// pings entirely (useful when higher-level timeouts manage session lifecycle).
96+
// Default: 1500 (25 minutes)
97+
KeepaliveInterval int `toml:"keepalive_interval" json:"keepalive_interval,omitempty"`
98+
9099
// PayloadDir is the directory for storing large payloads
91100
PayloadDir string `toml:"payload_dir" json:"payload_dir,omitempty"`
92101

@@ -110,6 +119,18 @@ type GatewayConfig struct {
110119
TrustedBots []string `toml:"trusted_bots" json:"trusted_bots,omitempty"`
111120
}
112121

122+
// HTTPKeepaliveInterval returns the keepalive interval as a time.Duration.
123+
// A negative KeepaliveInterval disables keepalive (returns 0).
124+
func (g *GatewayConfig) HTTPKeepaliveInterval() time.Duration {
125+
if g == nil {
126+
return time.Duration(DefaultKeepaliveInterval) * time.Second
127+
}
128+
if g.KeepaliveInterval < 0 {
129+
return 0
130+
}
131+
return time.Duration(g.KeepaliveInterval) * time.Second
132+
}
133+
113134
// GetAPIKey returns the gateway API key, handling a nil Gateway safely.
114135
func (c *Config) GetAPIKey() string {
115136
if c.Gateway == nil {
@@ -196,6 +217,9 @@ func applyGatewayDefaults(cfg *GatewayConfig) {
196217
if cfg.ToolTimeout == 0 {
197218
cfg.ToolTimeout = DefaultToolTimeout
198219
}
220+
if cfg.KeepaliveInterval == 0 {
221+
cfg.KeepaliveInterval = DefaultKeepaliveInterval
222+
}
199223
}
200224

201225
// EnsureGatewayDefaults guarantees that cfg.Gateway is non-nil and that all

internal/config/config_stdin.go

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,14 @@ type StdinConfig struct {
3232
// StdinGatewayConfig represents gateway configuration in stdin JSON format.
3333
// Uses pointers for optional fields to distinguish between unset and zero values.
3434
type StdinGatewayConfig struct {
35-
Port *int `json:"port,omitempty"`
36-
APIKey string `json:"apiKey,omitempty"`
37-
Domain string `json:"domain,omitempty"`
38-
StartupTimeout *int `json:"startupTimeout,omitempty"`
39-
ToolTimeout *int `json:"toolTimeout,omitempty"`
40-
PayloadDir string `json:"payloadDir,omitempty"`
41-
TrustedBots []string `json:"trustedBots,omitempty"`
35+
Port *int `json:"port,omitempty"`
36+
APIKey string `json:"apiKey,omitempty"`
37+
Domain string `json:"domain,omitempty"`
38+
StartupTimeout *int `json:"startupTimeout,omitempty"`
39+
ToolTimeout *int `json:"toolTimeout,omitempty"`
40+
KeepaliveInterval *int `json:"keepaliveInterval,omitempty"`
41+
PayloadDir string `json:"payloadDir,omitempty"`
42+
TrustedBots []string `json:"trustedBots,omitempty"`
4243
}
4344

4445
// StdinGuardConfig represents a guard configuration in stdin JSON format.
@@ -278,11 +279,12 @@ func convertStdinConfig(stdinCfg *StdinConfig) (*Config, error) {
278279
// Convert gateway config with defaults
279280
if stdinCfg.Gateway != nil {
280281
cfg.Gateway = &GatewayConfig{
281-
Port: intPtrOrDefault(stdinCfg.Gateway.Port, DefaultPort),
282-
APIKey: stdinCfg.Gateway.APIKey,
283-
Domain: stdinCfg.Gateway.Domain,
284-
StartupTimeout: intPtrOrDefault(stdinCfg.Gateway.StartupTimeout, DefaultStartupTimeout),
285-
ToolTimeout: intPtrOrDefault(stdinCfg.Gateway.ToolTimeout, DefaultToolTimeout),
282+
Port: intPtrOrDefault(stdinCfg.Gateway.Port, DefaultPort),
283+
APIKey: stdinCfg.Gateway.APIKey,
284+
Domain: stdinCfg.Gateway.Domain,
285+
StartupTimeout: intPtrOrDefault(stdinCfg.Gateway.StartupTimeout, DefaultStartupTimeout),
286+
ToolTimeout: intPtrOrDefault(stdinCfg.Gateway.ToolTimeout, DefaultToolTimeout),
287+
KeepaliveInterval: intPtrOrDefault(stdinCfg.Gateway.KeepaliveInterval, DefaultKeepaliveInterval),
286288
}
287289
if stdinCfg.Gateway.PayloadDir != "" {
288290
cfg.Gateway.PayloadDir = stdinCfg.Gateway.PayloadDir

internal/launcher/connection_pool.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ const (
3939

4040
// Default configuration values
4141
const (
42-
DefaultIdleTimeout = 30 * time.Minute
42+
// DefaultIdleTimeout is the maximum duration a STDIO-backend connection can remain unused
43+
// before being removed from the session pool. Set to 6 hours to accommodate long-running
44+
// workflow tasks (e.g. ML training, large builds) that may not make MCP calls for extended
45+
// periods. Note: this is distinct from HTTP backend keepalive (config.DefaultKeepaliveInterval)
46+
// which keeps the remote session alive on the HTTP server side; STDIO connections run as local
47+
// child processes whose sessions are bounded only by this pool eviction window.
48+
DefaultIdleTimeout = 6 * time.Hour
4349
DefaultCleanupInterval = 5 * time.Minute
4450
DefaultMaxErrorCount = 10
4551
)
@@ -152,10 +158,12 @@ func (p *SessionConnectionPool) cleanupIdleConnections() {
152158
logPool.Printf("Cleaning up connection: backend=%s, session=%s, reason=%s, idle=%v, errors=%d",
153159
key.BackendID, key.SessionID, reason, now.Sub(metadata.LastUsedAt), metadata.ErrorCount)
154160

155-
// Close the connection if still active
161+
// Close the underlying connection to release resources (cancel context, close SDK session)
156162
if metadata.Connection != nil && metadata.State != ConnectionStateClosed {
157-
// Note: mcp.Connection doesn't have a Close method in current implementation
158-
// but we mark it as closed
163+
if err := metadata.Connection.Close(); err != nil {
164+
logPool.Printf("Error closing connection during cleanup: backend=%s, session=%s, err=%v",
165+
key.BackendID, key.SessionID, err)
166+
}
159167
metadata.State = ConnectionStateClosed
160168
}
161169

internal/launcher/launcher.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ func GetOrLaunch(l *Launcher, serverID string) (*mcp.Connection, error) {
139139
}
140140

141141
// Create an HTTP connection
142-
conn, err := mcp.NewHTTPConnection(l.ctx, serverID, serverCfg.URL, serverCfg.Headers, oidcProvider, oidcAudience)
142+
conn, err := mcp.NewHTTPConnection(l.ctx, serverID, serverCfg.URL, serverCfg.Headers, oidcProvider, oidcAudience, l.config.Gateway.HTTPKeepaliveInterval())
143143
if err != nil {
144144
logger.LogErrorWithServer(serverID, "backend", "Failed to create HTTP connection: %s, error=%v", serverID, err)
145145
log.Printf("[LAUNCHER] ❌ FAILED to create HTTP connection for '%s'", serverID)

internal/mcp/connection.go

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ type Connection struct {
7070
httpClient *http.Client
7171
httpSessionID string // Session ID returned by the HTTP backend
7272
httpTransportType HTTPTransportType // Type of HTTP transport in use
73+
keepAliveInterval time.Duration // Keepalive interval for SDK transports (0 = disabled)
7374
// sessionMu protects the mutable session fields: httpSessionID, session, and client.
7475
// Always use getHTTPSessionID() or getSDKSession() to read these fields; the
7576
// reconnect functions (reconnectPlainJSON, reconnectSDKTransport) hold the full Lock.
@@ -98,8 +99,8 @@ func NewConnection(ctx context.Context, serverID, command string, args []string,
9899
logger.LogInfo("backend", "Creating new MCP backend connection, command=%s, args=%v", command, sanitize.SanitizeArgs(args))
99100
ctx, cancel := context.WithCancel(ctx)
100101

101-
// Create MCP client with logger
102-
client := newMCPClient(logConn)
102+
// Create MCP client with logger (no keepalive for stdio – the process lifespan manages the session)
103+
client := newMCPClient(logConn, 0)
103104

104105
// Expand Docker -e flags that reference environment variables
105106
// Docker's `-e VAR_NAME` expects VAR_NAME to be in the environment
@@ -196,7 +197,7 @@ func NewConnection(ctx context.Context, serverID, command string, args []string,
196197
// Authorization header from the headers map.
197198
//
198199
// This ensures compatibility with all types of HTTP MCP servers.
199-
func NewHTTPConnection(ctx context.Context, serverID, url string, headers map[string]string, oidcProvider *oidc.Provider, oidcAudience string) (*Connection, error) {
200+
func NewHTTPConnection(ctx context.Context, serverID, url string, headers map[string]string, oidcProvider *oidc.Provider, oidcAudience string, keepAlive time.Duration) (*Connection, error) {
200201
logger.LogInfo("backend", "Creating HTTP MCP connection with transport fallback, url=%s", url)
201202
ctx, cancel := context.WithCancel(ctx)
202203

@@ -234,7 +235,7 @@ func NewHTTPConnection(ctx context.Context, serverID, url string, headers map[st
234235

235236
// Try 1: Streamable HTTP (2025-03-26 spec)
236237
logConn.Printf("Attempting streamable HTTP transport for %s", url)
237-
conn, err := tryStreamableHTTPTransport(ctx, cancel, serverID, url, headers, headerClient)
238+
conn, err := tryStreamableHTTPTransport(ctx, cancel, serverID, url, headers, headerClient, keepAlive)
238239
if err == nil {
239240
logger.LogInfo("backend", "Successfully connected using streamable HTTP transport, url=%s", url)
240241
log.Printf("Configured HTTP MCP server with streamable transport: %s", url)
@@ -244,7 +245,7 @@ func NewHTTPConnection(ctx context.Context, serverID, url string, headers map[st
244245

245246
// Try 2: SSE (2024-11-05 spec)
246247
logConn.Printf("Attempting SSE transport for %s", url)
247-
conn, err = trySSETransport(ctx, cancel, serverID, url, headers, headerClient)
248+
conn, err = trySSETransport(ctx, cancel, serverID, url, headers, headerClient, keepAlive)
248249
if err == nil {
249250
logger.LogWarn("backend", "⚠️ MCP over SSE has been deprecated. Connected using SSE transport for url=%s. Please migrate to streamable HTTP transport (2025-03-26 spec).", url)
250251
log.Printf("⚠️ WARNING: MCP over SSE (2024-11-05 spec) has been DEPRECATED")
@@ -326,7 +327,8 @@ func (c *Connection) reconnectSDKTransport() error {
326327
headerClient := buildHTTPClientWithHeaders(c.httpClient, c.headers)
327328

328329
// Build the appropriate transport.
329-
client := newMCPClient(logConn)
330+
// Re-use the same keepAliveInterval so the reconnected session also sends periodic pings.
331+
client := newMCPClient(logConn, c.keepAliveInterval)
330332
var transport sdk.Transport
331333
switch c.httpTransportType {
332334
case HTTPTransportStreamable:
@@ -668,7 +670,9 @@ func (c *Connection) getPrompt(params interface{}) (*Response, error) {
668670
// Close closes the connection
669671
func (c *Connection) Close() error {
670672
logConn.Printf("Closing connection: serverID=%s, isHTTP=%v", c.serverID, c.isHTTP)
671-
c.cancel()
673+
if c.cancel != nil {
674+
c.cancel()
675+
}
672676
if session := c.getSDKSession(); session != nil {
673677
return session.Close()
674678
}

internal/mcp/connection_arguments_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ func TestCallTool_ArgumentsPassed(t *testing.T) {
159159
// Create connection
160160
conn, err := NewHTTPConnection(context.Background(), "test-server", testServer.URL, map[string]string{
161161
"Authorization": "test-token",
162-
}, nil, "")
162+
}, nil, "", 0)
163163
require.NoError(t, err, "Failed to create HTTP connection")
164164
defer conn.Close()
165165

@@ -224,7 +224,7 @@ func TestCallTool_MissingArguments(t *testing.T) {
224224

225225
conn, err := NewHTTPConnection(context.Background(), "test-server", testServer.URL, map[string]string{
226226
"Authorization": "test-token",
227-
}, nil, "")
227+
}, nil, "", 0)
228228
require.NoError(t, err)
229229
defer conn.Close()
230230

internal/mcp/connection_stderr_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func TestConnection_SendRequest(t *testing.T) {
3737

3838
conn, err := NewHTTPConnection(context.Background(), "test-server", srv.URL, map[string]string{
3939
"Authorization": "test-token",
40-
}, nil, "")
40+
}, nil, "", 0)
4141
require.NoError(t, err)
4242
defer conn.Close()
4343

internal/mcp/connection_test.go

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"strings"
1111
"testing"
12+
"time"
1213

1314
"github.com/github/gh-aw-mcpg/internal/config"
1415
"github.com/github/gh-aw-mcpg/internal/difc"
@@ -41,7 +42,7 @@ func TestHTTPRequest_SessionIDHeader(t *testing.T) {
4142
// Create an HTTP connection
4243
conn, err := NewHTTPConnection(context.Background(), "test-server", testServer.URL, map[string]string{
4344
"Authorization": "test-auth-token",
44-
}, nil, "")
45+
}, nil, "", 0)
4546
require.NoError(t, err, "Failed to create HTTP connection")
4647

4748
// Create a context with session ID
@@ -78,7 +79,7 @@ func TestHTTPRequest_NoSessionID(t *testing.T) {
7879
// Create an HTTP connection
7980
conn, err := NewHTTPConnection(context.Background(), "test-server", testServer.URL, map[string]string{
8081
"Authorization": "test-auth-token",
81-
}, nil, "")
82+
}, nil, "", 0)
8283
require.NoError(t, err, "Failed to create HTTP connection")
8384

8485
// Send a request without session ID in context
@@ -116,7 +117,7 @@ func TestHTTPRequest_ConfiguredHeaders(t *testing.T) {
116117
authToken := "configured-auth-token"
117118
conn, err := NewHTTPConnection(context.Background(), "test-server", testServer.URL, map[string]string{
118119
"Authorization": authToken,
119-
}, nil, "")
120+
}, nil, "", 0)
120121
require.NoError(t, err, "Failed to create HTTP connection")
121122

122123
// Create a context with session ID
@@ -375,7 +376,7 @@ func TestHTTPRequest_ErrorResponses(t *testing.T) {
375376
// Create connection with custom headers to use plain JSON transport
376377
conn, err := NewHTTPConnection(context.Background(), "test-server", testServer.URL, map[string]string{
377378
"Authorization": "test-token",
378-
}, nil, "")
379+
}, nil, "", 0)
379380
if err != nil && tt.expectError {
380381
// Error during initialization is expected for some error conditions
381382
if tt.errorSubstring != "" && !containsSubstring(err.Error(), tt.errorSubstring) {
@@ -427,7 +428,7 @@ func TestConnection_IsHTTP(t *testing.T) {
427428
"X-Custom": "custom-value",
428429
}
429430

430-
conn, err := NewHTTPConnection(context.Background(), "test-server", testServer.URL, headers, nil, "")
431+
conn, err := NewHTTPConnection(context.Background(), "test-server", testServer.URL, headers, nil, "", 0)
431432
require.NoError(t, err, "Failed to create HTTP connection")
432433
defer conn.Close()
433434

@@ -474,7 +475,7 @@ func TestHTTPConnection_InvalidURL(t *testing.T) {
474475

475476
for _, tt := range tests {
476477
t.Run(tt.name, func(t *testing.T) {
477-
_, err := NewHTTPConnection(context.Background(), "test-server", tt.url, tt.headers, nil, "")
478+
_, err := NewHTTPConnection(context.Background(), "test-server", tt.url, tt.headers, nil, "", 0)
478479

479480
if tt.expectError {
480481
if err == nil {
@@ -507,17 +508,54 @@ func stringContains(s, substr string) bool {
507508

508509
// TestNewMCPClient tests the newMCPClient helper function
509510
func TestNewMCPClient(t *testing.T) {
510-
client := newMCPClient(nil)
511+
client := newMCPClient(nil, 0)
511512
require.NotNil(t, client, "newMCPClient should return a non-nil client")
512513
}
513514

514515
// TestNewMCPClientWithLogger tests that newMCPClient accepts a logger
515516
func TestNewMCPClientWithLogger(t *testing.T) {
516517
log := logger.New("test:client")
517-
client := newMCPClient(log)
518+
client := newMCPClient(log, 0)
518519
require.NotNil(t, client, "newMCPClient should return a non-nil client with logger")
519520
}
520521

522+
// TestNewMCPClientWithKeepalive tests that newMCPClient accepts a keepalive interval
523+
func TestNewMCPClientWithKeepalive(t *testing.T) {
524+
keepAlive := time.Duration(config.DefaultKeepaliveInterval) * time.Second
525+
client := newMCPClient(nil, keepAlive)
526+
require.NotNil(t, client, "newMCPClient should return a non-nil client with keepalive")
527+
}
528+
529+
// TestDefaultKeepaliveInterval verifies the config keepalive default is less than a typical
530+
// backend session timeout (30 minutes) to prevent session expiry during long agent runs.
531+
func TestDefaultKeepaliveInterval(t *testing.T) {
532+
const typicalBackendTimeout = 30 * time.Minute
533+
keepAlive := time.Duration(config.DefaultKeepaliveInterval) * time.Second
534+
assert.Less(t, keepAlive, typicalBackendTimeout,
535+
"DefaultKeepaliveInterval must be less than the typical backend session timeout to prevent expiry")
536+
assert.Greater(t, keepAlive, time.Duration(0),
537+
"DefaultKeepaliveInterval must be positive")
538+
}
539+
540+
// TestNewHTTPConnectionStoresKeepalive verifies that the keepalive interval is stored on
541+
// the connection struct so that reconnectSDKTransport can recreate the session with the same setting.
542+
func TestNewHTTPConnectionStoresKeepalive(t *testing.T) {
543+
ctx, cancel := context.WithCancel(context.Background())
544+
defer cancel()
545+
546+
keepAlive := time.Duration(config.DefaultKeepaliveInterval) * time.Second
547+
client := newMCPClient(nil, keepAlive)
548+
url := "http://example.com/mcp"
549+
headers := map[string]string{}
550+
httpClient := &http.Client{}
551+
552+
conn := newHTTPConnection(ctx, cancel, client, nil, url, headers, httpClient, HTTPTransportStreamable, "test-server", keepAlive)
553+
554+
require.NotNil(t, conn)
555+
assert.Equal(t, keepAlive, conn.keepAliveInterval,
556+
"keepAliveInterval should be stored on the connection for use during reconnection")
557+
}
558+
521559
// TestSetupHTTPRequest tests the setupHTTPRequest helper function
522560
func TestSetupHTTPRequest(t *testing.T) {
523561
tests := []struct {
@@ -593,12 +631,12 @@ func TestNewHTTPConnection(t *testing.T) {
593631
ctx, cancel := context.WithCancel(context.Background())
594632
defer cancel()
595633

596-
client := newMCPClient(nil)
634+
client := newMCPClient(nil, 0)
597635
url := "http://example.com/mcp"
598636
headers := map[string]string{"Authorization": "test"}
599637
httpClient := &http.Client{}
600638

601-
conn := newHTTPConnection(ctx, cancel, client, nil, url, headers, httpClient, HTTPTransportStreamable, "test-server")
639+
conn := newHTTPConnection(ctx, cancel, client, nil, url, headers, httpClient, HTTPTransportStreamable, "test-server", 0)
602640

603641
require.NotNil(t, conn, "Connection should not be nil")
604642
assert.Equal(t, client, conn.client, "Client should match")

0 commit comments

Comments
 (0)