From 41a70539e72c802ff02a9f10e5fa7a2e49c66f0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 03:26:00 +0000 Subject: [PATCH 1/3] Initial plan From f1a2d2dbfae66be12c6612f777ec81cf8763312b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 03:43:10 +0000 Subject: [PATCH 2/3] feat: expose MCP gateway keepalive-interval in workflow config schema Adds `keepalive-interval` / `keepaliveInterval` configuration support to the MCP gateway, allowing workflow authors to control keepalive ping frequency for HTTP MCP backends to prevent session expiry during long-running agent tasks. Changes: - Add `KeepaliveInterval` field to `MCPGatewayRuntimeConfig` struct - Add frontmatter extraction for `keepaliveInterval`/`keepalive-interval` - Pass through keepalive interval in `buildMCPGatewayConfig` - Emit `keepaliveInterval` in gateway JSON when set (non-zero) - Update MCP gateway config JSON schema with `keepaliveInterval` field - Update main workflow schema with `keepalive-interval` in sandbox.mcp - Add tests for extraction and propagation - Document in MCP gateway spec (table, section 4.1.3.5, changelog) Closes #N/A (relates to gh-aw-mcpg#3079 and gh-aw#23153) Agent-Logs-Url: https://github.com/github/gh-aw/sessions/888a3ba5-2273-4afb-9f70-15385a5994b1 Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- .../schemas/mcp-gateway-config.schema.json | 5 ++ .../src/content/docs/reference/mcp-gateway.md | 48 +++++++++++++++++++ pkg/parser/schemas/main_workflow_schema.json | 6 +++ .../frontmatter_extraction_security.go | 21 ++++++++ .../frontmatter_extraction_security_test.go | 44 +++++++++++++++++ pkg/workflow/mcp_gateway_config.go | 1 + pkg/workflow/mcp_gateway_config_test.go | 37 ++++++++++++++ pkg/workflow/mcp_renderer.go | 3 ++ .../schemas/mcp-gateway-config.schema.json | 5 ++ pkg/workflow/tools_types.go | 1 + 10 files changed, 171 insertions(+) diff --git a/docs/public/schemas/mcp-gateway-config.schema.json b/docs/public/schemas/mcp-gateway-config.schema.json index 7d37896ef1a..cda369b7a0b 100644 --- a/docs/public/schemas/mcp-gateway-config.schema.json +++ b/docs/public/schemas/mcp-gateway-config.schema.json @@ -273,6 +273,11 @@ "minLength": 1 }, "minItems": 1 + }, + "keepaliveInterval": { + "type": "integer", + "description": "Keepalive ping interval in seconds for HTTP MCP backends. Sends periodic pings to prevent session expiry during long-running agent tasks. Set to -1 to disable keepalive pings. Unset or 0 uses the gateway default (1500 seconds = 25 minutes).", + "minimum": -1 } }, "required": ["port", "domain", "apiKey"], diff --git a/docs/src/content/docs/reference/mcp-gateway.md b/docs/src/content/docs/reference/mcp-gateway.md index bc1581f824f..cdb9c9160c2 100644 --- a/docs/src/content/docs/reference/mcp-gateway.md +++ b/docs/src/content/docs/reference/mcp-gateway.md @@ -250,6 +250,7 @@ The `gateway` section is required and configures gateway-specific behavior: | `payloadPathPrefix` | string | No | Path prefix to remap payload paths for agent containers (e.g., /workspace/payloads) | | `payloadSizeThreshold` | integer | No | Size threshold in bytes for storing payloads to disk (default: 524288 = 512KB) | | `trustedBots` | array[string] | No | Additional GitHub bot identity strings (e.g., `github-actions[bot]`) passed to the gateway and merged with its built-in trusted identity list. This field is additive — it extends the internal list but cannot remove built-in entries. | +| `keepaliveInterval` | integer | No | Keepalive ping interval in seconds for HTTP MCP backends. Prevents session expiry during long-running tasks. Use `-1` to disable, `0` or unset for gateway default (1500s = 25 min), or a positive integer for a custom interval. | #### 4.1.3.1 Payload Directory Path Validation @@ -409,6 +410,44 @@ sandbox: **Compliance Test**: T-AUTH-006 - Trusted Bot Identity Configuration +#### 4.1.3.5 Keepalive Interval Configuration + +The optional `keepaliveInterval` field in the gateway configuration controls how often the gateway sends periodic keepalive pings to HTTP MCP backends. This prevents idle session expiry during long-running agent tasks. + +| Value | Behavior | +|-------|----------| +| Unset / `0` | Gateway default: 1500 seconds (25 minutes) | +| `> 0` | Custom keepalive interval in seconds | +| `-1` | Disable keepalive pings entirely | + +**Configuration example (JSON)**: + +```json +{ + "gateway": { + "port": 8080, + "domain": "localhost", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "keepaliveInterval": 300 + } +} +``` + +**Workflow frontmatter** (via `sandbox.mcp.keepalive-interval`): + +```yaml +sandbox: + mcp: + keepalive-interval: 300 # 5-minute keepalive for backends with short idle timeouts +``` + +**Compliance rules**: + +- `keepaliveInterval` MUST be an integer when present +- A value of `0` is treated as unset by the gateway (silently defaults to 1500 seconds) +- A value of `-1` disables keepalive pings entirely +- Any positive integer sets the keepalive interval in seconds + #### 4.1.3a Top-Level Configuration Fields The following fields MAY be specified at the top level of the configuration: @@ -1629,6 +1668,15 @@ Content-Type: application/json ## Change Log +### Version 1.10.0 (Draft) + +- **Added**: `keepaliveInterval` field to gateway configuration (Section 4.1.3, 4.1.3.5) + - Optional integer (seconds) for controlling keepalive ping frequency to HTTP MCP backends + - Prevents session expiry during long-running agent tasks + - Workflow authors configure via `sandbox.mcp.keepalive-interval` in frontmatter; the compiler translates it into the gateway config +- **Added**: Section 4.1.3.5 — Keepalive Interval Configuration +- **Updated**: JSON Schema with `keepaliveInterval` property in `gatewayConfig` definition + ### Version 1.9.0 (Draft) - **Added**: `trustedBots` field to gateway configuration (Section 4.1.3, 4.1.3.4) diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 474e46cb63c..62d5ac4d202 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3230,6 +3230,12 @@ "type": "string", "enum": ["localhost", "host.docker.internal"], "description": "Gateway domain for URL generation (default: 'host.docker.internal' when agent is enabled, 'localhost' when disabled)" + }, + "keepalive-interval": { + "type": "integer", + "description": "Keepalive ping interval in seconds for HTTP MCP backends. Sends periodic pings to prevent session expiry during long-running agent tasks. Set to -1 to disable keepalive pings. Unset or 0 uses the gateway default (1500 seconds = 25 minutes).", + "minimum": -1, + "examples": [-1, 300, 600, 1500] } }, "additionalProperties": false diff --git a/pkg/workflow/frontmatter_extraction_security.go b/pkg/workflow/frontmatter_extraction_security.go index c9427844995..6ec9a1015be 100644 --- a/pkg/workflow/frontmatter_extraction_security.go +++ b/pkg/workflow/frontmatter_extraction_security.go @@ -495,6 +495,27 @@ func (c *Compiler) extractMCPGatewayConfig(mcpVal any) *MCPGatewayRuntimeConfig } } + // Extract keepaliveInterval / keepalive-interval (keepalive ping interval in seconds for HTTP MCP backends) + // 0 = unset (gateway default: 1500s), -1 = disable keepalive, >0 = custom interval in seconds + for _, key := range []string{"keepaliveInterval", "keepalive-interval"} { + if keepaliveVal, hasKeepalive := mcpObj[key]; hasKeepalive { + switch v := keepaliveVal.(type) { + case int: + mcpConfig.KeepaliveInterval = v + case int64: + mcpConfig.KeepaliveInterval = int(v) + case uint: + mcpConfig.KeepaliveInterval = int(v) + case uint64: + mcpConfig.KeepaliveInterval = int(v) + case float64: + mcpConfig.KeepaliveInterval = int(v) + } + // Break when the key exists (even if value is 0, to avoid picking up a second key variant) + break + } + } + return mcpConfig } diff --git a/pkg/workflow/frontmatter_extraction_security_test.go b/pkg/workflow/frontmatter_extraction_security_test.go index 8345b259a7e..4bc37cbdaf9 100644 --- a/pkg/workflow/frontmatter_extraction_security_test.go +++ b/pkg/workflow/frontmatter_extraction_security_test.go @@ -252,3 +252,47 @@ func TestExtractMCPGatewayConfigTrustedBots(t *testing.T) { assert.Nil(t, config.TrustedBots, "TrustedBots should be nil when not specified") }) } + +// TestExtractMCPGatewayConfigKeepaliveInterval tests extraction of keepalive-interval from MCP gateway frontmatter +func TestExtractMCPGatewayConfigKeepaliveInterval(t *testing.T) { + compiler := &Compiler{} + + t.Run("extracts keepaliveInterval using camelCase key", func(t *testing.T) { + mcpObj := map[string]any{ + "container": "ghcr.io/github/gh-aw-mcpg", + "keepaliveInterval": 300, + } + config := compiler.extractMCPGatewayConfig(mcpObj) + require.NotNil(t, config, "Should extract MCP gateway config") + assert.Equal(t, 300, config.KeepaliveInterval, "Should extract keepaliveInterval") + }) + + t.Run("extracts keepalive-interval using kebab-case key", func(t *testing.T) { + mcpObj := map[string]any{ + "container": "ghcr.io/github/gh-aw-mcpg", + "keepalive-interval": 600, + } + config := compiler.extractMCPGatewayConfig(mcpObj) + require.NotNil(t, config, "Should extract MCP gateway config") + assert.Equal(t, 600, config.KeepaliveInterval, "Should extract keepalive-interval") + }) + + t.Run("extracts -1 to disable keepalive", func(t *testing.T) { + mcpObj := map[string]any{ + "container": "ghcr.io/github/gh-aw-mcpg", + "keepaliveInterval": -1, + } + config := compiler.extractMCPGatewayConfig(mcpObj) + require.NotNil(t, config, "Should extract MCP gateway config") + assert.Equal(t, -1, config.KeepaliveInterval, "Should extract -1 as keepalive disabled sentinel") + }) + + t.Run("leaves keepaliveInterval as 0 when not specified", func(t *testing.T) { + mcpObj := map[string]any{ + "container": "ghcr.io/github/gh-aw-mcpg", + } + config := compiler.extractMCPGatewayConfig(mcpObj) + require.NotNil(t, config, "Should extract MCP gateway config") + assert.Equal(t, 0, config.KeepaliveInterval, "KeepaliveInterval should be 0 when not specified") + }) +} diff --git a/pkg/workflow/mcp_gateway_config.go b/pkg/workflow/mcp_gateway_config.go index 42aa59a0127..f3bf6ef747a 100644 --- a/pkg/workflow/mcp_gateway_config.go +++ b/pkg/workflow/mcp_gateway_config.go @@ -138,6 +138,7 @@ func buildMCPGatewayConfig(workflowData *WorkflowData) *MCPGatewayRuntimeConfig PayloadPathPrefix: workflowData.SandboxConfig.MCP.PayloadPathPrefix, // Optional path prefix for agent containers PayloadSizeThreshold: payloadSizeThreshold, // Size threshold in bytes TrustedBots: workflowData.SandboxConfig.MCP.TrustedBots, // Additional trusted bot identities from frontmatter + KeepaliveInterval: workflowData.SandboxConfig.MCP.KeepaliveInterval, // Keepalive interval from frontmatter (0=default, -1=disabled, >0=custom) } } diff --git a/pkg/workflow/mcp_gateway_config_test.go b/pkg/workflow/mcp_gateway_config_test.go index 9eef167e15c..840796557ca 100644 --- a/pkg/workflow/mcp_gateway_config_test.go +++ b/pkg/workflow/mcp_gateway_config_test.go @@ -333,6 +333,42 @@ func TestBuildMCPGatewayConfig(t *testing.T) { TrustedBots: []string{"github-actions[bot]", "copilot-swe-agent[bot]"}, }, }, + { + name: "propagates custom keepaliveInterval from frontmatter config", + workflowData: &WorkflowData{ + SandboxConfig: &SandboxConfig{ + MCP: &MCPGatewayRuntimeConfig{ + KeepaliveInterval: 300, + }, + }, + }, + expected: &MCPGatewayRuntimeConfig{ + Port: int(DefaultMCPGatewayPort), + Domain: "${MCP_GATEWAY_DOMAIN}", + APIKey: "${MCP_GATEWAY_API_KEY}", + PayloadDir: "${MCP_GATEWAY_PAYLOAD_DIR}", + PayloadSizeThreshold: constants.DefaultMCPGatewayPayloadSizeThreshold, + KeepaliveInterval: 300, + }, + }, + { + name: "propagates disabled keepaliveInterval (-1) from frontmatter config", + workflowData: &WorkflowData{ + SandboxConfig: &SandboxConfig{ + MCP: &MCPGatewayRuntimeConfig{ + KeepaliveInterval: -1, + }, + }, + }, + expected: &MCPGatewayRuntimeConfig{ + Port: int(DefaultMCPGatewayPort), + Domain: "${MCP_GATEWAY_DOMAIN}", + APIKey: "${MCP_GATEWAY_API_KEY}", + PayloadDir: "${MCP_GATEWAY_PAYLOAD_DIR}", + PayloadSizeThreshold: constants.DefaultMCPGatewayPayloadSizeThreshold, + KeepaliveInterval: -1, + }, + }, } for _, tt := range tests { @@ -349,6 +385,7 @@ func TestBuildMCPGatewayConfig(t *testing.T) { assert.Equal(t, tt.expected.PayloadPathPrefix, result.PayloadPathPrefix, "PayloadPathPrefix should match") assert.Equal(t, tt.expected.PayloadSizeThreshold, result.PayloadSizeThreshold, "PayloadSizeThreshold should match") assert.Equal(t, tt.expected.TrustedBots, result.TrustedBots, "TrustedBots should match") + assert.Equal(t, tt.expected.KeepaliveInterval, result.KeepaliveInterval, "KeepaliveInterval should match") } }) } diff --git a/pkg/workflow/mcp_renderer.go b/pkg/workflow/mcp_renderer.go index eb7a18efa34..1d68010d7bc 100644 --- a/pkg/workflow/mcp_renderer.go +++ b/pkg/workflow/mcp_renderer.go @@ -190,6 +190,9 @@ func RenderJSONMCPConfig( } configBuilder.WriteString("]") } + if options.GatewayConfig.KeepaliveInterval != 0 { + fmt.Fprintf(&configBuilder, ",\n \"keepaliveInterval\": %d", options.GatewayConfig.KeepaliveInterval) + } configBuilder.WriteString("\n") configBuilder.WriteString(" }\n") } else { diff --git a/pkg/workflow/schemas/mcp-gateway-config.schema.json b/pkg/workflow/schemas/mcp-gateway-config.schema.json index d5063297b6c..ac4d456084e 100644 --- a/pkg/workflow/schemas/mcp-gateway-config.schema.json +++ b/pkg/workflow/schemas/mcp-gateway-config.schema.json @@ -242,6 +242,11 @@ "minLength": 1 }, "minItems": 1 + }, + "keepaliveInterval": { + "type": "integer", + "description": "Keepalive ping interval in seconds for HTTP MCP backends. Sends periodic pings to prevent session expiry during long-running agent tasks. Set to -1 to disable keepalive pings. Unset or 0 uses the gateway default (1500 seconds = 25 minutes).", + "minimum": -1 } }, "required": ["port", "domain", "apiKey"], diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go index c3e46ecd879..c6172638015 100644 --- a/pkg/workflow/tools_types.go +++ b/pkg/workflow/tools_types.go @@ -512,6 +512,7 @@ type MCPGatewayRuntimeConfig struct { PayloadPathPrefix string `yaml:"payload-path-prefix,omitempty"` // Path prefix to remap payload paths for agent containers (e.g., /workspace/payloads) PayloadSizeThreshold int `yaml:"payload-size-threshold,omitempty"` // Size threshold in bytes for storing payloads to disk (default: 524288 = 512KB) TrustedBots []string `yaml:"trusted-bots,omitempty"` // Additional bot identity strings to pass to the gateway, merged with its built-in list + KeepaliveInterval int `yaml:"keepalive-interval,omitempty"` // Keepalive ping interval in seconds for HTTP MCP backends (0=default 1500s, -1=disabled, >0=custom) } // HasTool checks if a tool is present in the configuration From 789f62f0d585cd9a3983280ae0e1d376bfcf2177 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Thu, 2 Apr 2026 20:55:10 -0700 Subject: [PATCH 3/3] test: fix check_workflow_timestamp_api tests for updated error messages Tests asserted 'integrity check failed' but the error message was changed to 'is outdated or unverifiable' in #24198. Updated all 6 assertions to match the current production error format. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../setup/js/check_workflow_timestamp_api.test.cjs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/actions/setup/js/check_workflow_timestamp_api.test.cjs b/actions/setup/js/check_workflow_timestamp_api.test.cjs index 7a4d227e0a4..f9a1e50ca37 100644 --- a/actions/setup/js/check_workflow_timestamp_api.test.cjs +++ b/actions/setup/js/check_workflow_timestamp_api.test.cjs @@ -234,7 +234,7 @@ jobs: await main(); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not compare frontmatter hashes")); - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("integrity check failed")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("is outdated")); expect(mockCore.summary.addRaw).toHaveBeenCalled(); expect(mockCore.summary.write).toHaveBeenCalled(); }); @@ -245,7 +245,7 @@ jobs: await main(); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not compare frontmatter hashes")); - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("integrity check failed")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("is outdated")); expect(mockCore.summary.addRaw).toHaveBeenCalled(); expect(mockCore.summary.write).toHaveBeenCalled(); }); @@ -347,7 +347,7 @@ engine: claude expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Unable to fetch lock file content")); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not compare frontmatter hashes")); - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("integrity check failed")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("is outdated")); }); }); @@ -699,7 +699,7 @@ engine: copilot expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Unable to fetch lock file content for hash comparison via API")); expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("GITHUB_WORKSPACE not available")); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not compare frontmatter hashes")); - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("integrity check failed")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("is outdated")); }); it("should fail when API fails and local lock file is missing", async () => { @@ -711,7 +711,7 @@ engine: copilot expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Local lock file not found")); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not compare frontmatter hashes")); - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("integrity check failed")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("is outdated")); }); it("should use API if available even in cross-repo scenario (API preferred over local files)", async () => { @@ -784,7 +784,7 @@ engine: copilot // The path traversal is rejected before any file read expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("escapes workspace")); expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining("Could not compare frontmatter hashes")); - expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("integrity check failed")); + expect(mockCore.setFailed).toHaveBeenCalledWith(expect.stringContaining("is outdated")); }); }); });