diff --git a/.github/aw/schemas/agentic-workflow.json b/.github/aw/schemas/agentic-workflow.json index 0812933b1f..7b32955802 100644 --- a/.github/aw/schemas/agentic-workflow.json +++ b/.github/aw/schemas/agentic-workflow.json @@ -2312,6 +2312,34 @@ "api-key": { "type": "string", "description": "API key for authenticating with the MCP gateway (supports ${{ secrets.* }} syntax)" + }, + "domain": { + "type": "string", + "enum": ["localhost", "host.docker.internal"], + "description": "Gateway domain for URL generation (default: 'host.docker.internal' when agent is enabled, 'localhost' when disabled)" + }, + "mounts": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[^:]+:[^:]+:(ro|rw)$" + }, + "description": "Volume mounts for the gateway container (format: 'source:dest:mode' where mode is 'ro' or 'rw')", + "examples": [["/host/data:/container/data:ro", "/host/config:/container/config:rw"]] + }, + "network": { + "type": "string", + "description": "Docker network mode for the gateway container (default: 'host')", + "examples": ["host", "bridge", "none"] + }, + "ports": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(\\d+:\\d+|\\d+)$" + }, + "description": "Port mappings for the gateway container (format: 'host:container' or 'port')", + "examples": [["8080:8080", "9090:9090"]] } }, "required": ["container"], diff --git a/docs/src/content/docs/reference/mcp-gateway.md b/docs/src/content/docs/reference/mcp-gateway.md index 3f47071bc6..bc063ff9ce 100644 --- a/docs/src/content/docs/reference/mcp-gateway.md +++ b/docs/src/content/docs/reference/mcp-gateway.md @@ -195,7 +195,8 @@ The gateway MUST accept configuration via stdin in JSON format conforming to the "apiKey": "string", "domain": "string", "startupTimeout": 30, - "toolTimeout": 60 + "toolTimeout": 60, + "mounts": ["source:dest:mode"] } } ``` @@ -228,6 +229,7 @@ The optional `gateway` section configures gateway-specific behavior: | `domain` | string | localhost | Gateway domain (localhost or host.docker.internal) | | `startupTimeout` | integer | 30 | Server startup timeout in seconds | | `toolTimeout` | integer | 60 | Tool invocation timeout in seconds | +| `mounts` | array[string] | [] | Volume mounts for gateway container (format: "source:dest:mode") | ### 4.2 Variable Expression Rendering @@ -744,7 +746,28 @@ Implementations SHOULD provide: } ``` -#### A.2 Mixed Transport Configuration +#### A.2 Gateway with Volume Mounts + +```json +{ + "mcpServers": { + "data-server": { + "container": "ghcr.io/example/data-mcp:latest", + "type": "stdio" + } + }, + "gateway": { + "port": 8080, + "apiKey": "gateway-secret-token", + "mounts": [ + "/host/data:/container/data:ro", + "/host/config:/container/config:rw" + ] + } +} +``` + +#### A.3 Mixed Transport Configuration ```json { @@ -767,7 +790,7 @@ Implementations SHOULD provide: } ``` -#### A.3 GitHub MCP Server (Containerized) +#### A.4 GitHub MCP Server (Containerized) ```json { diff --git a/examples/mcp-gateway-with-volumes.md b/examples/mcp-gateway-with-volumes.md new file mode 100644 index 0000000000..b239260256 --- /dev/null +++ b/examples/mcp-gateway-with-volumes.md @@ -0,0 +1,44 @@ +--- +on: workflow_dispatch +engine: copilot +features: + mcp-gateway: true + +# Example: MCP Gateway with Volume Mounts +# This example demonstrates how to configure volume mounts for the MCP Gateway. + +sandbox: + agent: awf + mcp: + # Container image for the gateway + container: ghcr.io/example/mcp-gateway + version: latest + + # Volume mounts (format: "source:dest:mode") + # - source: host path + # - dest: container path + # - mode: "ro" (read-only) or "rw" (read-write) + mounts: + - "/host/data:/data:ro" # Read-only data mount + - "/host/config:/config:rw" # Read-write config mount + + # Environment variables for the gateway + env: + LOG_LEVEL: debug + DEBUG: "true" + +tools: + bash: ["*"] +--- + +# MCP Gateway with Volume Mounts + +This workflow demonstrates how to configure the MCP Gateway with volume mounts. + +## Task + +Show the contents of the data directory that was mounted from the host. + +```bash +ls -la /data +``` diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 0812933b1f..d49073d2e0 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -2312,6 +2312,20 @@ "api-key": { "type": "string", "description": "API key for authenticating with the MCP gateway (supports ${{ secrets.* }} syntax)" + }, + "domain": { + "type": "string", + "enum": ["localhost", "host.docker.internal"], + "description": "Gateway domain for URL generation (default: 'host.docker.internal' when agent is enabled, 'localhost' when disabled)" + }, + "mounts": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[^:]+:[^:]+:(ro|rw)$" + }, + "description": "Volume mounts for the gateway container (format: 'source:dest:mode' where mode is 'ro' or 'rw')", + "examples": [["/host/data:/container/data:ro", "/host/config:/container/config:rw"]] } }, "required": ["container"], diff --git a/pkg/workflow/frontmatter_extraction_security.go b/pkg/workflow/frontmatter_extraction_security.go index 74a2b3e283..90fa342f3b 100644 --- a/pkg/workflow/frontmatter_extraction_security.go +++ b/pkg/workflow/frontmatter_extraction_security.go @@ -393,6 +393,17 @@ func (c *Compiler) extractMCPGatewayConfig(mcpVal any) *MCPGatewayRuntimeConfig } } + // Extract mounts (volume mounts) + if mountsVal, hasMounts := mcpObj["mounts"]; hasMounts { + if mountsSlice, ok := mountsVal.([]any); ok { + for _, mount := range mountsSlice { + if mountStr, ok := mount.(string); ok { + mcpConfig.Mounts = append(mcpConfig.Mounts, mountStr) + } + } + } + } + return mcpConfig } diff --git a/pkg/workflow/mcp_gateway_config_test.go b/pkg/workflow/mcp_gateway_config_test.go new file mode 100644 index 0000000000..db8c34a687 --- /dev/null +++ b/pkg/workflow/mcp_gateway_config_test.go @@ -0,0 +1,210 @@ +package workflow + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMCPGatewayMountsConfiguration tests that volume mounts are properly handled in MCP gateway configuration +func TestMCPGatewayMountsConfiguration(t *testing.T) { + tests := []struct { + name string + sandboxConfig *SandboxConfig + expectMounts []string + expectError bool + expectInDocker bool + }{ + { + name: "valid mounts configuration", + sandboxConfig: &SandboxConfig{ + MCP: &MCPGatewayRuntimeConfig{ + Container: "ghcr.io/example/gateway:latest", + Mounts: []string{ + "/host/data:/container/data:ro", + "/host/config:/container/config:rw", + }, + }, + }, + expectMounts: []string{"/host/data:/container/data:ro", "/host/config:/container/config:rw"}, + expectError: false, + expectInDocker: true, + }, + { + name: "no mounts configured", + sandboxConfig: &SandboxConfig{ + MCP: &MCPGatewayRuntimeConfig{ + Container: "ghcr.io/example/gateway:latest", + Mounts: []string{}, + }, + }, + expectMounts: []string{}, + expectError: false, + expectInDocker: false, + }, + { + name: "invalid mount syntax - missing mode", + sandboxConfig: &SandboxConfig{ + MCP: &MCPGatewayRuntimeConfig{ + Container: "ghcr.io/example/gateway:latest", + Mounts: []string{ + "/host/data:/container/data", + }, + }, + }, + expectMounts: nil, + expectError: true, + expectInDocker: false, + }, + { + name: "invalid mount syntax - invalid mode", + sandboxConfig: &SandboxConfig{ + MCP: &MCPGatewayRuntimeConfig{ + Container: "ghcr.io/example/gateway:latest", + Mounts: []string{ + "/host/data:/container/data:xyz", + }, + }, + }, + expectMounts: nil, + expectError: true, + expectInDocker: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workflowData := &WorkflowData{ + SandboxConfig: tt.sandboxConfig, + } + + // Validate the configuration + err := validateSandboxConfig(workflowData) + if tt.expectError { + assert.Error(t, err, "Expected validation error") + return + } + require.NoError(t, err, "Unexpected validation error") + + // If mounts are expected, verify they're present + if len(tt.expectMounts) > 0 { + assert.ElementsMatch(t, tt.expectMounts, workflowData.SandboxConfig.MCP.Mounts, + "Mounts should match expected values") + } + }) + } +} + +// TestMCPGatewayDockerCommandGeneration tests that docker command includes mounts +func TestMCPGatewayDockerCommandGeneration(t *testing.T) { + tests := []struct { + name string + gatewayConfig *MCPGatewayRuntimeConfig + expectInCommand []string + expectNotInCmd []string + }{ + { + name: "mounts included in docker command", + gatewayConfig: &MCPGatewayRuntimeConfig{ + Container: "ghcr.io/example/gateway:latest", + Mounts: []string{ + "/host/data:/container/data:ro", + "/host/config:/container/config:rw", + }, + }, + expectInCommand: []string{ + "-v /host/config:/container/config:rw", + "-v /host/data:/container/data:ro", + }, + }, + { + name: "default network mode is host", + gatewayConfig: &MCPGatewayRuntimeConfig{ + Container: "ghcr.io/example/gateway:latest", + }, + expectInCommand: []string{ + "--network host", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a minimal workflow data with MCP gateway enabled + workflowData := &WorkflowData{ + SandboxConfig: &SandboxConfig{ + MCP: tt.gatewayConfig, + }, + Features: map[string]any{ + "mcp-gateway": true, + }, + } + + // Generate the docker command by calling the generation function + var yamlBuilder strings.Builder + engine := &CopilotEngine{} + generateMCPGatewayStepInline(&yamlBuilder, engine, workflowData) + + dockerCmd := yamlBuilder.String() + + // Verify expected strings are present + for _, expected := range tt.expectInCommand { + assert.Contains(t, dockerCmd, expected, + "Docker command should contain '%s'", expected) + } + + // Verify strings that should not be present + for _, notExpected := range tt.expectNotInCmd { + assert.NotContains(t, dockerCmd, notExpected, + "Docker command should not contain '%s'", notExpected) + } + }) + } +} + +// TestMCPGatewayExtraction tests that the extraction function properly parses mounts +func TestMCPGatewayExtraction(t *testing.T) { + tests := []struct { + name string + mcpConfig map[string]any + expectMounts []string + }{ + { + name: "extract mounts", + mcpConfig: map[string]any{ + "container": "ghcr.io/example/gateway:latest", + "mounts": []any{ + "/host/data:/container/data:ro", + "/host/config:/container/config:rw", + }, + }, + expectMounts: []string{ + "/host/data:/container/data:ro", + "/host/config:/container/config:rw", + }, + }, + { + name: "no mounts", + mcpConfig: map[string]any{ + "container": "ghcr.io/example/gateway:latest", + }, + expectMounts: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := &Compiler{} + extracted := compiler.extractMCPGatewayConfig(tt.mcpConfig) + + require.NotNil(t, extracted, "Extraction should not return nil") + + if len(tt.expectMounts) > 0 { + assert.ElementsMatch(t, tt.expectMounts, extracted.Mounts, + "Mounts should match expected values") + } + }) + } +} diff --git a/pkg/workflow/mcp_servers.go b/pkg/workflow/mcp_servers.go index cd36405ab6..b4d2927e0f 100644 --- a/pkg/workflow/mcp_servers.go +++ b/pkg/workflow/mcp_servers.go @@ -559,6 +559,17 @@ func generateMCPGatewayStepInline(yaml *strings.Builder, engine CodingAgentEngin // Build container command with args containerCmd := "docker run -i --rm --network host" + // Add volume mounts if configured + if len(gatewayConfig.Mounts) > 0 { + // Sort mounts for stable code generation + sortedMounts := make([]string, len(gatewayConfig.Mounts)) + copy(sortedMounts, gatewayConfig.Mounts) + sort.Strings(sortedMounts) + for _, mount := range sortedMounts { + containerCmd += " -v " + shellQuote(mount) + } + } + // Add environment variables to container containerCmd += " -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY" if len(gatewayConfig.Env) > 0 { diff --git a/pkg/workflow/sandbox_validation.go b/pkg/workflow/sandbox_validation.go index c84f8fb82c..5f9c113735 100644 --- a/pkg/workflow/sandbox_validation.go +++ b/pkg/workflow/sandbox_validation.go @@ -63,7 +63,7 @@ func validateSandboxConfig(workflowData *WorkflowData) error { sandboxConfig := workflowData.SandboxConfig - // Validate mounts syntax if specified + // Validate mounts syntax if specified in agent config agentConfig := getAgentConfig(workflowData) if agentConfig != nil && len(agentConfig.Mounts) > 0 { if err := validateMountsSyntax(agentConfig.Mounts); err != nil { @@ -71,6 +71,13 @@ func validateSandboxConfig(workflowData *WorkflowData) error { } } + // Validate mounts syntax if specified in MCP gateway config + if sandboxConfig.MCP != nil && len(sandboxConfig.MCP.Mounts) > 0 { + if err := validateMountsSyntax(sandboxConfig.MCP.Mounts); err != nil { + return fmt.Errorf("invalid MCP gateway mount configuration: %w", err) + } + } + // Validate that SRT is only used with Copilot engine if isSRTEnabled(workflowData) { // Check if the sandbox-runtime feature flag is enabled diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go index 1b8e131152..f1a9405eab 100644 --- a/pkg/workflow/tools_types.go +++ b/pkg/workflow/tools_types.go @@ -313,6 +313,7 @@ type MCPGatewayRuntimeConfig struct { Port int `yaml:"port,omitempty"` // Port for the gateway HTTP server (default: 8080) APIKey string `yaml:"api-key,omitempty"` // API key for gateway authentication Domain string `yaml:"domain,omitempty"` // Domain for gateway URL (localhost or host.docker.internal) + Mounts []string `yaml:"mounts,omitempty"` // Volume mounts for gateway container (format: "source:dest:mode") } // HasTool checks if a tool is present in the configuration