Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions actions/setup/js/check_workflow_timestamp_api.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -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();
});
Expand Down Expand Up @@ -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"));
});
});

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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"));
});
});
});
5 changes: 5 additions & 0 deletions docs/public/schemas/mcp-gateway-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
48 changes: 48 additions & 0 deletions docs/src/content/docs/reference/mcp-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
Comment on lines +3234 to 3239
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The compiler explicitly accepts both keepaliveInterval (camelCase) and keepalive-interval (kebab-case) in frontmatter, but this schema only allows keepalive-interval (and additionalProperties: false will reject the camelCase variant). Either add keepaliveInterval to the schema as an allowed alias (optionally marked deprecated) or drop camelCase support in extraction to keep validation/intellisense aligned with actual accepted inputs.

Copilot uses AI. Check for mistakes.
},
"additionalProperties": false
Expand Down
21 changes: 21 additions & 0 deletions pkg/workflow/frontmatter_extraction_security.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +500 to +516
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loop breaks as soon as either key exists, even if the value is an unsupported type (e.g. string/bool). That prevents falling back to the other key variant, and can silently ignore a valid value under the second spelling. Consider only breaking once a value has been successfully parsed as a number (including explicit 0), and emit a warning/error when the key is present but not a valid integer. Also consider enforcing the documented constraint of >= -1 (reject values < -1) before propagating to the gateway config.

Copilot uses AI. Check for mistakes.
}

return mcpConfig
}

Expand Down
44 changes: 44 additions & 0 deletions pkg/workflow/frontmatter_extraction_security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}
1 change: 1 addition & 0 deletions pkg/workflow/mcp_gateway_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
37 changes: 37 additions & 0 deletions pkg/workflow/mcp_gateway_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
}
})
}
Expand Down
3 changes: 3 additions & 0 deletions pkg/workflow/mcp_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions pkg/workflow/schemas/mcp-gateway-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/tools_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading