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
28 changes: 28 additions & 0 deletions .github/aw/schemas/agentic-workflow.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
29 changes: 26 additions & 3 deletions docs/src/content/docs/reference/mcp-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
}
```
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
{
Expand All @@ -767,7 +790,7 @@ Implementations SHOULD provide:
}
```

#### A.3 GitHub MCP Server (Containerized)
#### A.4 GitHub MCP Server (Containerized)

```json
{
Expand Down
44 changes: 44 additions & 0 deletions examples/mcp-gateway-with-volumes.md
Original file line number Diff line number Diff line change
@@ -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
```
14 changes: 14 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
11 changes: 11 additions & 0 deletions pkg/workflow/frontmatter_extraction_security.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
210 changes: 210 additions & 0 deletions pkg/workflow/mcp_gateway_config_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
}
Loading
Loading