diff --git a/.github/aw/schemas/agentic-workflow.json b/.github/aw/schemas/agentic-workflow.json index 6957252fb1..56aacaef4a 100644 --- a/.github/aw/schemas/agentic-workflow.json +++ b/.github/aw/schemas/agentic-workflow.json @@ -1977,10 +1977,14 @@ "description": "MCP Gateway configuration for routing MCP server calls through a unified HTTP gateway. Requires the 'mcp-gateway' feature flag to be enabled.", "type": "object", "properties": { + "command": { + "type": "string", + "description": "Custom command to execute the MCP gateway (mutually exclusive with 'container')" + }, "container": { "type": "string", "pattern": "^[a-zA-Z0-9][a-zA-Z0-9/:_.-]*$", - "description": "Container image for the MCP gateway executable" + "description": "Container image for the MCP gateway executable (mutually exclusive with 'command')" }, "version": { "type": ["string", "number"], @@ -1992,14 +1996,14 @@ "items": { "type": "string" }, - "description": "Arguments for container execution" + "description": "Arguments for command or docker run" }, "entrypointArgs": { "type": "array", "items": { "type": "string" }, - "description": "Arguments to add after the container image (container entrypoint arguments)" + "description": "Arguments to add after the container image (container entrypoint arguments, only valid with 'container')" }, "env": { "type": "object", diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 6957252fb1..56aacaef4a 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1977,10 +1977,14 @@ "description": "MCP Gateway configuration for routing MCP server calls through a unified HTTP gateway. Requires the 'mcp-gateway' feature flag to be enabled.", "type": "object", "properties": { + "command": { + "type": "string", + "description": "Custom command to execute the MCP gateway (mutually exclusive with 'container')" + }, "container": { "type": "string", "pattern": "^[a-zA-Z0-9][a-zA-Z0-9/:_.-]*$", - "description": "Container image for the MCP gateway executable" + "description": "Container image for the MCP gateway executable (mutually exclusive with 'command')" }, "version": { "type": ["string", "number"], @@ -1992,14 +1996,14 @@ "items": { "type": "string" }, - "description": "Arguments for container execution" + "description": "Arguments for command or docker run" }, "entrypointArgs": { "type": "array", "items": { "type": "string" }, - "description": "Arguments to add after the container image (container entrypoint arguments)" + "description": "Arguments to add after the container image (container entrypoint arguments, only valid with 'container')" }, "env": { "type": "object", diff --git a/pkg/workflow/gateway.go b/pkg/workflow/gateway.go index 16c368e7bc..9fc6bb6a57 100644 --- a/pkg/workflow/gateway.go +++ b/pkg/workflow/gateway.go @@ -2,6 +2,7 @@ package workflow import ( "fmt" + "strings" "github.com/githubnext/gh-aw/pkg/logger" ) @@ -80,10 +81,6 @@ func generateMCPGatewayStartStep(config *MCPGatewayConfig, mcpServersConfig map[ // MCP config file path (created by RenderMCPConfig) mcpConfigPath := "/home/runner/.copilot/mcp-config.json" - // Detect action mode at compile time - actionMode := DetectActionMode() - gatewayLog.Printf("Generating gateway step for action mode: %s", actionMode) - stepLines := []string{ " - name: Start MCP Gateway", " run: |", @@ -92,11 +89,141 @@ func generateMCPGatewayStartStep(config *MCPGatewayConfig, mcpServersConfig map[ " ", } + // Check which mode to use: container, command, or default (awmg binary) + if config.Container != "" { + // Container mode + gatewayLog.Printf("Using container mode: %s", config.Container) + stepLines = append(stepLines, generateContainerStartCommands(config, mcpConfigPath, port)...) + } else if config.Command != "" { + // Custom command mode + gatewayLog.Printf("Using custom command mode: %s", config.Command) + stepLines = append(stepLines, generateCommandStartCommands(config, mcpConfigPath, port)...) + } else { + // Default mode: use awmg binary + gatewayLog.Print("Using default mode: awmg binary") + stepLines = append(stepLines, generateDefaultAWMGCommands(config, mcpConfigPath, port)...) + } + + return GitHubActionStep(stepLines) +} + +// generateContainerStartCommands generates shell commands to start the MCP gateway using a Docker container +func generateContainerStartCommands(config *MCPGatewayConfig, mcpConfigPath string, port int) []string { + var lines []string + + // Build environment variables + var envFlags []string + if len(config.Env) > 0 { + for key, value := range config.Env { + envFlags = append(envFlags, fmt.Sprintf("-e %s=\"%s\"", key, value)) + } + } + envFlagsStr := strings.Join(envFlags, " ") + + // Build docker run command with args + dockerCmd := "docker run" + + // Add args (e.g., --rm, -i, -v, -p) + if len(config.Args) > 0 { + for _, arg := range config.Args { + dockerCmd += " " + arg + } + } + + // Add environment variables + if envFlagsStr != "" { + dockerCmd += " " + envFlagsStr + } + + // Add container image + containerImage := config.Container + if config.Version != "" { + containerImage += ":" + config.Version + } + dockerCmd += " " + containerImage + + // Add entrypoint args + if len(config.EntrypointArgs) > 0 { + for _, arg := range config.EntrypointArgs { + dockerCmd += " " + arg + } + } + + lines = append(lines, + " # Start MCP gateway using Docker container", + fmt.Sprintf(" echo 'Starting MCP Gateway container: %s'", config.Container), + " ", + " # Pipe MCP config to container via stdin", + fmt.Sprintf(" cat %s | %s > %s/gateway.log 2>&1 &", mcpConfigPath, dockerCmd, MCPGatewayLogsFolder), + " GATEWAY_PID=$!", + " echo \"MCP Gateway container started with PID $GATEWAY_PID\"", + " ", + " # Give the gateway a moment to start", + " sleep 2", + ) + + return lines +} + +// generateCommandStartCommands generates shell commands to start the MCP gateway using a custom command +func generateCommandStartCommands(config *MCPGatewayConfig, mcpConfigPath string, port int) []string { + var lines []string + + // Build the command with args + command := config.Command + if len(config.Args) > 0 { + command += " " + strings.Join(config.Args, " ") + } + + // Build environment variables + var envVars []string + if len(config.Env) > 0 { + for key, value := range config.Env { + envVars = append(envVars, fmt.Sprintf("export %s=\"%s\"", key, value)) + } + } + + lines = append(lines, + " # Start MCP gateway using custom command", + fmt.Sprintf(" echo 'Starting MCP Gateway with command: %s'", config.Command), + " ", + ) + + // Add environment variables if any + if len(envVars) > 0 { + lines = append(lines, " # Set environment variables") + for _, envVar := range envVars { + lines = append(lines, " "+envVar) + } + lines = append(lines, " ") + } + + lines = append(lines, + " # Start the command in background", + fmt.Sprintf(" cat %s | %s > %s/gateway.log 2>&1 &", mcpConfigPath, command, MCPGatewayLogsFolder), + " GATEWAY_PID=$!", + " echo \"MCP Gateway started with PID $GATEWAY_PID\"", + " ", + " # Give the gateway a moment to start", + " sleep 2", + ) + + return lines +} + +// generateDefaultAWMGCommands generates shell commands to start the MCP gateway using the default awmg binary +func generateDefaultAWMGCommands(config *MCPGatewayConfig, mcpConfigPath string, port int) []string { + var lines []string + + // Detect action mode at compile time + actionMode := DetectActionMode() + gatewayLog.Printf("Generating gateway step for action mode: %s", actionMode) + // Generate different installation code based on compile-time mode if actionMode == ActionModeDev { // Development mode: build from sources gatewayLog.Print("Using development mode - will build awmg from sources") - stepLines = append(stepLines, + lines = append(lines, " # Development mode: Build awmg from sources", " if [ -f \"cmd/awmg/main.go\" ] && [ -f \"Makefile\" ]; then", " echo 'Building awmg from sources (development mode)...'", @@ -125,7 +252,7 @@ func generateMCPGatewayStartStep(config *MCPGatewayConfig, mcpServersConfig map[ } else { // Release mode: download from GitHub releases gatewayLog.Print("Using release mode - will download awmg from releases") - stepLines = append(stepLines, + lines = append(lines, " # Release mode: Download awmg from releases", " # Check if awmg is already in PATH", " if command -v awmg &> /dev/null; then", @@ -165,7 +292,7 @@ func generateMCPGatewayStartStep(config *MCPGatewayConfig, mcpServersConfig map[ ) } - stepLines = append(stepLines, + lines = append(lines, " ", " # Start MCP gateway in background with config file", fmt.Sprintf(" $AWMG_CMD --config %s --port %d --log-dir %s > %s/gateway.log 2>&1 &", mcpConfigPath, port, MCPGatewayLogsFolder, MCPGatewayLogsFolder), @@ -176,7 +303,7 @@ func generateMCPGatewayStartStep(config *MCPGatewayConfig, mcpServersConfig map[ " sleep 2", ) - return GitHubActionStep(stepLines) + return lines } // generateMCPGatewayHealthCheckStep generates the step that pings the gateway to verify it's running @@ -199,7 +326,7 @@ func generateMCPGatewayHealthCheckStep(config *MCPGatewayConfig) GitHubActionSte " echo 'Waiting for MCP Gateway to be ready...'", " ", " # Show MCP config file content", - fmt.Sprintf(" echo 'MCP Configuration:'"), + " echo 'MCP Configuration:'", fmt.Sprintf(" cat %s || echo 'No MCP config file found'", mcpConfigPath), " echo ''", " ", @@ -262,7 +389,7 @@ func generateMCPGatewayHealthCheckStep(config *MCPGatewayConfig) GitHubActionSte " echo \"Error: MCP Gateway failed to start after $max_retries attempts\"", " ", " # Show gateway logs for debugging", - fmt.Sprintf(" echo 'Gateway logs:'"), + " echo 'Gateway logs:'", fmt.Sprintf(" cat %s/gateway.log || echo 'No gateway logs found'", MCPGatewayLogsFolder), " exit 1", } diff --git a/pkg/workflow/gateway_test.go b/pkg/workflow/gateway_test.go index 1a56225454..4d59c8c4a7 100644 --- a/pkg/workflow/gateway_test.go +++ b/pkg/workflow/gateway_test.go @@ -392,3 +392,156 @@ func TestSandboxConfigWithMCP(t *testing.T) { require.NotNil(t, sandboxConfig.Agent) assert.Equal(t, SandboxTypeAWF, sandboxConfig.Agent.Type) } + +func TestGenerateContainerStartCommands(t *testing.T) { + config := &MCPGatewayConfig{ + Container: "ghcr.io/githubnext/gh-aw-mcpg:latest", + Args: []string{"--rm", "-i", "-v", "/var/run/docker.sock:/var/run/docker.sock", "-p", "8000:8000", "--entrypoint", "/app/flowguard-go"}, + EntrypointArgs: []string{"--routed", "--listen", "0.0.0.0:8000", "--config-stdin"}, + Port: 8000, + Env: map[string]string{ + "DOCKER_API_VERSION": "1.44", + }, + } + + mcpConfigPath := "/home/runner/.copilot/mcp-config.json" + lines := generateContainerStartCommands(config, mcpConfigPath, 8000) + output := strings.Join(lines, "\n") + + // Verify container mode is indicated + assert.Contains(t, output, "Start MCP gateway using Docker container") + assert.Contains(t, output, "ghcr.io/githubnext/gh-aw-mcpg:latest") + + // Verify docker run command is constructed correctly + assert.Contains(t, output, "docker run") + assert.Contains(t, output, "--rm") + assert.Contains(t, output, "-i") + assert.Contains(t, output, "-v") + assert.Contains(t, output, "/var/run/docker.sock:/var/run/docker.sock") + assert.Contains(t, output, "-p") + assert.Contains(t, output, "8000:8000") + assert.Contains(t, output, "--entrypoint") + assert.Contains(t, output, "/app/flowguard-go") + + // Verify environment variables are set + assert.Contains(t, output, "-e DOCKER_API_VERSION=\"1.44\"") + + // Verify entrypoint args + assert.Contains(t, output, "--routed") + assert.Contains(t, output, "--listen") + assert.Contains(t, output, "0.0.0.0:8000") + assert.Contains(t, output, "--config-stdin") + + // Verify config is piped via stdin + assert.Contains(t, output, "cat /home/runner/.copilot/mcp-config.json |") + assert.Contains(t, output, MCPGatewayLogsFolder) +} + +func TestGenerateCommandStartCommands(t *testing.T) { + config := &MCPGatewayConfig{ + Command: "/usr/local/bin/mcp-gateway", + Args: []string{"--port", "8080", "--verbose"}, + Port: 8080, + Env: map[string]string{ + "LOG_LEVEL": "debug", + "API_KEY": "test-key", + }, + } + + mcpConfigPath := "/home/runner/.copilot/mcp-config.json" + lines := generateCommandStartCommands(config, mcpConfigPath, 8080) + output := strings.Join(lines, "\n") + + // Verify command mode is indicated + assert.Contains(t, output, "Start MCP gateway using custom command") + assert.Contains(t, output, "/usr/local/bin/mcp-gateway") + + // Verify command with args + assert.Contains(t, output, "/usr/local/bin/mcp-gateway --port 8080 --verbose") + + // Verify environment variables are exported + assert.Contains(t, output, "export LOG_LEVEL=\"debug\"") + assert.Contains(t, output, "export API_KEY=\"test-key\"") + + // Verify config is piped via stdin + assert.Contains(t, output, "cat /home/runner/.copilot/mcp-config.json |") + assert.Contains(t, output, MCPGatewayLogsFolder) +} + +func TestGenerateDefaultAWMGCommands(t *testing.T) { + config := &MCPGatewayConfig{ + Port: 8080, + } + + mcpConfigPath := "/home/runner/.copilot/mcp-config.json" + lines := generateDefaultAWMGCommands(config, mcpConfigPath, 8080) + output := strings.Join(lines, "\n") + + // Verify awmg binary handling + assert.Contains(t, output, "awmg") + assert.Contains(t, output, "AWMG_CMD") + + // Verify config file and port + assert.Contains(t, output, "--config /home/runner/.copilot/mcp-config.json") + assert.Contains(t, output, "--port 8080") + assert.Contains(t, output, MCPGatewayLogsFolder) +} + +func TestGenerateMCPGatewayStartStep_ContainerMode(t *testing.T) { + config := &MCPGatewayConfig{ + Container: "ghcr.io/githubnext/gh-aw-mcpg:latest", + Args: []string{"--rm", "-i"}, + EntrypointArgs: []string{"--config-stdin"}, + Port: 8000, + } + mcpServers := map[string]any{ + "github": map[string]any{}, + } + + step := generateMCPGatewayStartStep(config, mcpServers) + stepStr := strings.Join(step, "\n") + + // Should use container mode + assert.Contains(t, stepStr, "Start MCP Gateway") + assert.Contains(t, stepStr, "docker run") + assert.Contains(t, stepStr, "ghcr.io/githubnext/gh-aw-mcpg:latest") + assert.NotContains(t, stepStr, "awmg") // Should not use awmg +} + +func TestGenerateMCPGatewayStartStep_CommandMode(t *testing.T) { + config := &MCPGatewayConfig{ + Command: "/usr/local/bin/custom-gateway", + Args: []string{"--debug"}, + Port: 9000, + } + mcpServers := map[string]any{ + "github": map[string]any{}, + } + + step := generateMCPGatewayStartStep(config, mcpServers) + stepStr := strings.Join(step, "\n") + + // Should use command mode + assert.Contains(t, stepStr, "Start MCP Gateway") + assert.Contains(t, stepStr, "/usr/local/bin/custom-gateway --debug") + assert.NotContains(t, stepStr, "docker run") // Should not use docker + assert.NotContains(t, stepStr, "awmg") // Should not use awmg +} + +func TestGenerateMCPGatewayStartStep_DefaultMode(t *testing.T) { + config := &MCPGatewayConfig{ + Port: 8080, + } + mcpServers := map[string]any{ + "github": map[string]any{}, + } + + step := generateMCPGatewayStartStep(config, mcpServers) + stepStr := strings.Join(step, "\n") + + // Should use default awmg mode + assert.Contains(t, stepStr, "Start MCP Gateway") + assert.Contains(t, stepStr, "awmg") + assert.NotContains(t, stepStr, "docker run") // Should not use docker + assert.NotContains(t, stepStr, "/usr/local/bin/custom-gateway") // Should not use custom command +} diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index 477005997d..169500a040 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -326,5 +326,20 @@ func validateSandboxConfig(workflowData *WorkflowData) error { } } + // Validate MCP gateway configuration + if sandboxConfig.MCP != nil { + mcpConfig := sandboxConfig.MCP + + // Validate mutual exclusivity of command and container + if mcpConfig.Command != "" && mcpConfig.Container != "" { + return fmt.Errorf("sandbox.mcp: cannot specify both 'command' and 'container', use one or the other") + } + + // Validate entrypointArgs is only used with container + if len(mcpConfig.EntrypointArgs) > 0 && mcpConfig.Container == "" { + return fmt.Errorf("sandbox.mcp: 'entrypointArgs' can only be used with 'container'") + } + } + return nil } diff --git a/pkg/workflow/sandbox_test.go b/pkg/workflow/sandbox_test.go index 5c83ca9587..352515f5cd 100644 --- a/pkg/workflow/sandbox_test.go +++ b/pkg/workflow/sandbox_test.go @@ -292,6 +292,67 @@ func TestValidateSandboxConfig(t *testing.T) { expectError: true, errorMsg: "sandbox-runtime and AWF firewall cannot be used together", }, + { + name: "MCP gateway with both command and container fails", + data: &WorkflowData{ + SandboxConfig: &SandboxConfig{ + MCP: &MCPGatewayConfig{ + Command: "/usr/bin/gateway", + Container: "ghcr.io/gateway:latest", + }, + }, + }, + expectError: true, + errorMsg: "cannot specify both 'command' and 'container'", + }, + { + name: "MCP gateway with entrypointArgs without container fails", + data: &WorkflowData{ + SandboxConfig: &SandboxConfig{ + MCP: &MCPGatewayConfig{ + Command: "/usr/bin/gateway", + EntrypointArgs: []string{"--config-stdin"}, + }, + }, + }, + expectError: true, + errorMsg: "'entrypointArgs' can only be used with 'container'", + }, + { + name: "MCP gateway with container only is valid", + data: &WorkflowData{ + SandboxConfig: &SandboxConfig{ + MCP: &MCPGatewayConfig{ + Container: "ghcr.io/gateway:latest", + EntrypointArgs: []string{"--config-stdin"}, + }, + }, + }, + expectError: false, + }, + { + name: "MCP gateway with command only is valid", + data: &WorkflowData{ + SandboxConfig: &SandboxConfig{ + MCP: &MCPGatewayConfig{ + Command: "/usr/bin/gateway", + Args: []string{"--port", "8080"}, + }, + }, + }, + expectError: false, + }, + { + name: "MCP gateway with neither command nor container is valid", + data: &WorkflowData{ + SandboxConfig: &SandboxConfig{ + MCP: &MCPGatewayConfig{ + Port: 8080, + }, + }, + }, + expectError: false, + }, } for _, tt := range tests { diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go index 6405cae70a..44e890cd54 100644 --- a/pkg/workflow/tools_parser.go +++ b/pkg/workflow/tools_parser.go @@ -431,6 +431,9 @@ func parseMCPGatewayTool(val any) *MCPGatewayConfig { Port: DefaultMCPGatewayPort, } + if command, ok := configMap["command"].(string); ok { + config.Command = command + } if container, ok := configMap["container"].(string); ok { config.Container = container } diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go index 5138ef3d66..4484fcb96c 100644 --- a/pkg/workflow/tools_types.go +++ b/pkg/workflow/tools_types.go @@ -233,10 +233,11 @@ type CacheMemoryToolConfig struct { // MCPGatewayConfig represents the configuration for the MCP gateway // The gateway routes MCP server calls through a unified HTTP endpoint type MCPGatewayConfig struct { - Container string `yaml:"container,omitempty"` // Container image for the gateway + Command string `yaml:"command,omitempty"` // Custom command to execute (mutually exclusive with Container) + Container string `yaml:"container,omitempty"` // Container image for the gateway (mutually exclusive with Command) Version string `yaml:"version,omitempty"` // Optional version/tag for the container - Args []string `yaml:"args,omitempty"` // Arguments for container execution - EntrypointArgs []string `yaml:"entrypointArgs,omitempty"` // Arguments after the container image + Args []string `yaml:"args,omitempty"` // Arguments for command or docker run + EntrypointArgs []string `yaml:"entrypointArgs,omitempty"` // Arguments passed to container entrypoint (container only) Env map[string]string `yaml:"env,omitempty"` // Environment variables for the gateway Port int `yaml:"port,omitempty"` // Port for the gateway HTTP server (default: 8080) APIKey string `yaml:"api-key,omitempty"` // API key for gateway authentication