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
6 changes: 6 additions & 0 deletions pkg/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,12 @@ const DefaultMCPStartupTimeout = 120 * time.Second
// MCPSessionTimeoutMin is the minimum allowed value for engine.mcp.session-timeout (5 minutes).
const MCPSessionTimeoutMin = 5 * time.Minute

// MCPToolTimeoutMin is the minimum allowed value for engine.mcp.tool-timeout (10 seconds).
const MCPToolTimeoutMin = 10 * time.Second

// MCPToolTimeoutMax is the maximum allowed value for engine.mcp.tool-timeout (600 seconds).
const MCPToolTimeoutMax = 600 * time.Second

// DefaultActivationJobRunnerImage is the default runner image for activation and pre-activation jobs
const DefaultActivationJobRunnerImage = "ubuntu-slim"

Expand Down
5 changes: 5 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -9902,6 +9902,11 @@
"type": "string",
"description": "Session timeout for MCP gateway sessions as a Go duration string (e.g. \"30m\", \"4h\", \"24h\"). Must be at least 5m (no upper bound). Omitted or empty uses the effective gateway default (precedence: this field > MCP_GATEWAY_SESSION_TIMEOUT env var > built-in default 6h). Longer timeouts benefit multi-hour workflows such as large-scale migrations; shorter values free gateway resources sooner.",
"examples": ["30m", "1h", "4h", "6h", "12h"]
},
"tool-timeout": {
"type": "string",
"description": "Timeout for individual MCP tool calls as a Go duration string (e.g. \"30s\", \"2m\", \"10m\"). Must be between 10s and 600s inclusive. Omitted or empty uses the gateway built-in default (60s). Use a higher value for slow MCP backends such as full-text search over large indexes.",
"examples": ["30s", "2m", "5m", "10m"]
}
},
"additionalProperties": false
Expand Down
5 changes: 5 additions & 0 deletions pkg/workflow/compiler_orchestrator_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error)
return nil, fmt.Errorf("%s: %w", cleanPath, err)
}

// Validate optional engine.mcp.tool-timeout configuration.
if err := c.validateEngineMCPToolTimeout(workflowData); err != nil {
return nil, fmt.Errorf("%s: %w", cleanPath, err)
}

// Validate that inlined-imports is not used with agent file imports.
// Agent files require runtime access and cannot be resolved without sources.
if workflowData.InlinedImports && engineSetup.importsResult.AgentFile != "" {
Expand Down
5 changes: 5 additions & 0 deletions pkg/workflow/compiler_string_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ func (c *Compiler) ParseWorkflowString(content string, virtualPath string) (*Wor
return nil, fmt.Errorf("%s: %w", cleanPath, err)
}

// Validate optional engine.mcp.tool-timeout configuration.
if err := c.validateEngineMCPToolTimeout(workflowData); err != nil {
return nil, fmt.Errorf("%s: %w", cleanPath, err)
}

// Validate GitHub tool configuration
if err := validateGitHubToolConfig(workflowData.ParsedTools, workflowData.Name); err != nil {
return nil, fmt.Errorf("%s: %w", cleanPath, err)
Expand Down
8 changes: 8 additions & 0 deletions pkg/workflow/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type EngineConfig struct {

// MCP gateway configuration from engine.mcp sub-object
MCPSessionTimeout string // session-timeout: Go duration string for MCP gateway sessions (e.g. "4h", "30m")
MCPToolTimeout string // tool-timeout: Go duration string for individual MCP tool calls (e.g. "2m", "30s")
}

// NetworkPermissions represents network access permissions for workflow execution
Expand Down Expand Up @@ -331,6 +332,13 @@ func (c *Compiler) ExtractEngineConfig(frontmatter map[string]any) (string, *Eng
engineLog.Printf("Extracted engine.mcp.session-timeout: %s", config.MCPSessionTimeout)
}
}
// Extract tool-timeout (kebab-case only; camelCase is not supported)
if ttVal, hasToolTimeout := mcpObj["tool-timeout"]; hasToolTimeout {
if ttStr, ok := ttVal.(string); ok && ttStr != "" {
config.MCPToolTimeout = ttStr
engineLog.Printf("Extracted engine.mcp.tool-timeout: %s", config.MCPToolTimeout)
}
}
}
}

Expand Down
68 changes: 68 additions & 0 deletions pkg/workflow/engine_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -925,3 +925,71 @@ func TestEngineMCPSessionTimeoutExtraction(t *testing.T) {
})
}
}

// TestEngineMCPToolTimeoutExtraction tests extraction of engine.mcp.tool-timeout.
func TestEngineMCPToolTimeoutExtraction(t *testing.T) {
compiler := NewCompiler()

tests := []struct {
name string
frontmatter map[string]any
expectedTimeout string
}{
{
name: "extracts tool-timeout from engine.mcp",
frontmatter: map[string]any{
"engine": map[string]any{
"id": "copilot",
"mcp": map[string]any{
"tool-timeout": "2m",
},
},
},
expectedTimeout: "2m",
},
{
name: "no mcp section - empty tool timeout",
frontmatter: map[string]any{
"engine": map[string]any{
"id": "copilot",
},
},
expectedTimeout: "",
},
{
name: "mcp section without tool-timeout - empty",
frontmatter: map[string]any{
"engine": map[string]any{
"id": "copilot",
"mcp": map[string]any{},
},
},
expectedTimeout: "",
},
{
name: "mcp section with both session-timeout and tool-timeout",
frontmatter: map[string]any{
"engine": map[string]any{
"id": "copilot",
"mcp": map[string]any{
"session-timeout": "4h",
"tool-timeout": "5m",
},
},
},
expectedTimeout: "5m",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, config := compiler.ExtractEngineConfig(tt.frontmatter)
if config == nil {
t.Fatal("Expected non-nil config")
}
if config.MCPToolTimeout != tt.expectedTimeout {
t.Errorf("MCPToolTimeout = %q, want %q", config.MCPToolTimeout, tt.expectedTimeout)
}
})
}
}
26 changes: 26 additions & 0 deletions pkg/workflow/engine_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,32 @@ func (c *Compiler) validateEngineMCPSessionTimeout(workflowData *WorkflowData) e
return nil
}

// validateEngineMCPToolTimeout validates optional engine.mcp.tool-timeout configuration.
// The value must be a valid Go duration string between 10s and 600s inclusive.
func (c *Compiler) validateEngineMCPToolTimeout(workflowData *WorkflowData) error {
if workflowData == nil || workflowData.EngineConfig == nil || workflowData.EngineConfig.MCPToolTimeout == "" {
return nil
}

raw := workflowData.EngineConfig.MCPToolTimeout

d, err := time.ParseDuration(raw)
if err != nil {
return fmt.Errorf("engine.mcp.tool-timeout: invalid duration %q. Must be a valid Go duration string (e.g. \"30s\", \"2m\", \"10m\").\n\nExamples:\n engine:\n mcp:\n tool-timeout: 2m\n\nSee: %s", raw, constants.DocsEnginesURL)
}

if d < constants.MCPToolTimeoutMin {
return fmt.Errorf("engine.mcp.tool-timeout: %q is too short (minimum is 10s).\n\nExamples:\n tool-timeout: 30s\n tool-timeout: 2m\n\nSee: %s", raw, constants.DocsEnginesURL)
}

if d > constants.MCPToolTimeoutMax {
return fmt.Errorf("engine.mcp.tool-timeout: %q exceeds the maximum allowed value (600s / 10m).\n\nExamples:\n tool-timeout: 2m\n tool-timeout: 10m\n\nSee: %s", raw, constants.DocsEnginesURL)
}

engineValidationLog.Printf("engine.mcp.tool-timeout validated: %s (%s)", raw, d)
return nil
}

// validateEngineInlineDefinition validates an inline engine definition parsed from
// engine.runtime + optional engine.provider in the workflow frontmatter.
// Returns an error if:
Expand Down
120 changes: 120 additions & 0 deletions pkg/workflow/engine_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -505,3 +505,123 @@ func TestValidateEngineMCPSessionTimeout(t *testing.T) {
})
}
}

// TestValidateEngineMCPToolTimeout tests the validateEngineMCPToolTimeout function.
func TestValidateEngineMCPToolTimeout(t *testing.T) {
tests := []struct {
name string
workflow *WorkflowData
expectError bool
errorSubstr string
}{
{
name: "nil workflow data",
workflow: nil,
expectError: false,
},
{
name: "nil engine config",
workflow: &WorkflowData{},
expectError: false,
},
{
name: "empty tool timeout - no error",
workflow: &WorkflowData{
EngineConfig: &EngineConfig{ID: "copilot"},
},
expectError: false,
},
{
name: "valid duration 2m",
workflow: &WorkflowData{
EngineConfig: &EngineConfig{ID: "copilot", MCPToolTimeout: "2m"},
},
expectError: false,
},
{
name: "valid duration 30s",
workflow: &WorkflowData{
EngineConfig: &EngineConfig{ID: "copilot", MCPToolTimeout: "30s"},
},
expectError: false,
},
{
name: "valid duration 10s (minimum)",
workflow: &WorkflowData{
EngineConfig: &EngineConfig{ID: "copilot", MCPToolTimeout: "10s"},
},
expectError: false,
},
{
name: "valid duration 600s (maximum)",
workflow: &WorkflowData{
EngineConfig: &EngineConfig{ID: "copilot", MCPToolTimeout: "600s"},
},
expectError: false,
},
{
name: "valid duration 10m (maximum)",
workflow: &WorkflowData{
EngineConfig: &EngineConfig{ID: "copilot", MCPToolTimeout: "10m"},
},
expectError: false,
},
{
name: "invalid duration string",
workflow: &WorkflowData{
EngineConfig: &EngineConfig{ID: "copilot", MCPToolTimeout: "2hours"},
},
expectError: true,
errorSubstr: "invalid duration",
},
{
name: "too short - 5s",
workflow: &WorkflowData{
EngineConfig: &EngineConfig{ID: "copilot", MCPToolTimeout: "5s"},
},
expectError: true,
errorSubstr: "too short",
},
{
name: "too long - 601s",
workflow: &WorkflowData{
EngineConfig: &EngineConfig{ID: "copilot", MCPToolTimeout: "601s"},
},
expectError: true,
errorSubstr: "exceeds the maximum",
},
{
name: "too long - 11m",
workflow: &WorkflowData{
EngineConfig: &EngineConfig{ID: "copilot", MCPToolTimeout: "11m"},
},
expectError: true,
errorSubstr: "exceeds the maximum",
},
{
name: "plain integer - not valid Go duration",
workflow: &WorkflowData{
EngineConfig: &EngineConfig{ID: "copilot", MCPToolTimeout: "120"},
},
expectError: true,
errorSubstr: "invalid duration",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
compiler := NewCompiler()
err := compiler.validateEngineMCPToolTimeout(tt.workflow)

if tt.expectError {
require.Error(t, err, "Expected validation error")
if tt.errorSubstr != "" {
assert.Contains(t, err.Error(), tt.errorSubstr, "Expected error substring mismatch")
}
return
}

assert.NoError(t, err, "Expected tool-timeout validation to pass")
})
}
}
5 changes: 4 additions & 1 deletion pkg/workflow/mcp_gateway_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,11 @@ func buildMCPGatewayConfig(workflowData *WorkflowData) *MCPGatewayRuntimeConfig
// are written directly into the gateway config JSON.
//
// SessionTimeout comes from engine.mcp.session-timeout in frontmatter (via EngineConfig).
var sessionTimeout string
// ToolTimeout comes from engine.mcp.tool-timeout in frontmatter (via EngineConfig).
var sessionTimeout, toolTimeout string
if workflowData.EngineConfig != nil {
sessionTimeout = workflowData.EngineConfig.MCPSessionTimeout
toolTimeout = workflowData.EngineConfig.MCPToolTimeout
}
return &MCPGatewayRuntimeConfig{
Port: int(DefaultMCPGatewayPort), // Will be formatted as "${MCP_GATEWAY_PORT}" in renderer
Expand All @@ -150,6 +152,7 @@ func buildMCPGatewayConfig(workflowData *WorkflowData) *MCPGatewayRuntimeConfig
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)
SessionTimeout: sessionTimeout, // Session timeout from engine.mcp.session-timeout (empty = gateway default 6h)
ToolTimeout: toolTimeout, // Tool timeout from engine.mcp.tool-timeout (empty = gateway built-in default 60s)
// OTLPEndpoint and OTLPHeaders are set from workflowData by injectOTLPConfig, which is
// the fully resolved OTLP config (including imports). Using these fields ensures gateway
// OTLP config honours observability defined in imported shared workflows.
Expand Down
39 changes: 39 additions & 0 deletions pkg/workflow/mcp_gateway_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,44 @@ func TestBuildMCPGatewayConfig(t *testing.T) {
SessionTimeout: "",
},
},
{
name: "propagates tool-timeout from engine config",
workflowData: &WorkflowData{
EngineConfig: &EngineConfig{
ID: "copilot",
MCPToolTimeout: "2m",
},
SandboxConfig: &SandboxConfig{},
},
expected: &MCPGatewayRuntimeConfig{
Port: int(DefaultMCPGatewayPort),
Domain: "${MCP_GATEWAY_DOMAIN}",
APIKey: "${MCP_GATEWAY_API_KEY}",
PayloadDir: "${MCP_GATEWAY_PAYLOAD_DIR}",
PayloadSizeThreshold: constants.DefaultMCPGatewayPayloadSizeThreshold,
ToolTimeout: "2m",
},
},
{
name: "propagates both session-timeout and tool-timeout from engine config",
workflowData: &WorkflowData{
EngineConfig: &EngineConfig{
ID: "copilot",
MCPSessionTimeout: "4h",
MCPToolTimeout: "5m",
},
SandboxConfig: &SandboxConfig{},
},
expected: &MCPGatewayRuntimeConfig{
Port: int(DefaultMCPGatewayPort),
Domain: "${MCP_GATEWAY_DOMAIN}",
APIKey: "${MCP_GATEWAY_API_KEY}",
PayloadDir: "${MCP_GATEWAY_PAYLOAD_DIR}",
PayloadSizeThreshold: constants.DefaultMCPGatewayPayloadSizeThreshold,
SessionTimeout: "4h",
ToolTimeout: "5m",
},
},
}

for _, tt := range tests {
Expand All @@ -419,6 +457,7 @@ func TestBuildMCPGatewayConfig(t *testing.T) {
assert.Equal(t, tt.expected.TrustedBots, result.TrustedBots, "TrustedBots should match")
assert.Equal(t, tt.expected.KeepaliveInterval, result.KeepaliveInterval, "KeepaliveInterval should match")
assert.Equal(t, tt.expected.SessionTimeout, result.SessionTimeout, "SessionTimeout should match")
assert.Equal(t, tt.expected.ToolTimeout, result.ToolTimeout, "ToolTimeout 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 @@ -191,6 +191,9 @@ func RenderJSONMCPConfig(
if options.GatewayConfig.SessionTimeout != "" {
fmt.Fprintf(&configBuilder, ",\n \"sessionTimeout\": %q", options.GatewayConfig.SessionTimeout)
}
if options.GatewayConfig.ToolTimeout != "" {
fmt.Fprintf(&configBuilder, ",\n \"toolTimeout\": %q", options.GatewayConfig.ToolTimeout)
}
// When OTLP tracing is configured, add the opentelemetry section directly to the
// gateway config. The endpoint is passed via the OTEL_EXPORTER_OTLP_ENDPOINT env var
// (injected by injectOTLPConfig) so that secrets are never interpolated directly into
Expand Down
Loading
Loading