From 3abb6da21553894c1185aa773c2b09684c143147 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:33:50 +0000 Subject: [PATCH 1/4] Initial plan From 7126c596cdcd248f4ead9e6d6647acc304e78f6a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:37:37 +0000 Subject: [PATCH 2/4] Add TOML validation to enforce Docker containerization requirement Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- internal/config/config_core.go | 5 + internal/config/validation.go | 26 +++++ internal/config/validation_test.go | 164 +++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+) diff --git a/internal/config/config_core.go b/internal/config/config_core.go index b3a21ecc9..61c8d862f 100644 --- a/internal/config/config_core.go +++ b/internal/config/config_core.go @@ -202,6 +202,11 @@ func LoadFromFile(path string) (*Config, error) { return nil, fmt.Errorf("no servers defined in configuration") } + // Validate TOML stdio servers use Docker for containerization (Spec Section 3.2.1) + if err := validateTOMLStdioContainerization(cfg.Servers); err != nil { + return nil, err + } + // Initialize gateway if not present if cfg.Gateway == nil { cfg.Gateway = &GatewayConfig{} diff --git a/internal/config/validation.go b/internal/config/validation.go index 167b5eaab..90aa0a628 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -262,3 +262,29 @@ func validateGatewayConfig(gateway *StdinGatewayConfig) error { logValidation.Print("Gateway config validation passed") return nil } + +// validateTOMLStdioContainerization validates that TOML stdio servers use Docker for containerization. +// This enforces MCP Gateway Specification Section 3.2.1: "Stdio-based MCP servers MUST be containerized." +func validateTOMLStdioContainerization(servers map[string]*ServerConfig) error { + logValidation.Print("Validating TOML stdio server containerization requirement") + + for name, cfg := range servers { + // Only validate stdio servers (or empty type which defaults to stdio) + if cfg.Type == "" || cfg.Type == "stdio" || cfg.Type == "local" { + logValidation.Printf("Checking stdio server: name=%s, command=%s", name, cfg.Command) + + // Check if command is Docker + if cfg.Command != "docker" { + logValidation.Printf("Validation failed: stdio server using non-Docker command, name=%s, command=%s", name, cfg.Command) + return fmt.Errorf( + "server '%s': stdio servers must use containerized execution (command must be 'docker', got '%s'). "+ + "This is required by MCP Gateway Specification Section 3.2.1 (Containerization Requirement). "+ + "See: https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/mcp-gateway.md#321-containerization-requirement", + name, cfg.Command) + } + } + } + + logValidation.Print("TOML stdio containerization validation passed") + return nil +} diff --git a/internal/config/validation_test.go b/internal/config/validation_test.go index 4b4afd048..89e68ba0e 100644 --- a/internal/config/validation_test.go +++ b/internal/config/validation_test.go @@ -712,3 +712,167 @@ func TestLoadFromStdin_ValidationErrors(t *testing.T) { } // Helper function - defined in validation_string_patterns_test.go + +func TestValidateTOMLStdioContainerization(t *testing.T) { + tests := []struct { + name string + servers map[string]*ServerConfig + shouldErr bool + errorMsg string + }{ + { + name: "valid Docker command for stdio server", + servers: map[string]*ServerConfig{ + "github": { + Type: "stdio", + Command: "docker", + Args: []string{"run", "--rm", "-i", "ghcr.io/github/github-mcp-server:latest"}, + }, + }, + shouldErr: false, + }, + { + name: "valid Docker command with empty type (defaults to stdio)", + servers: map[string]*ServerConfig{ + "github": { + Type: "", + Command: "docker", + Args: []string{"run", "--rm", "-i", "ghcr.io/github/github-mcp-server:latest"}, + }, + }, + shouldErr: false, + }, + { + name: "valid Docker command with local type (alias for stdio)", + servers: map[string]*ServerConfig{ + "github": { + Type: "local", + Command: "docker", + Args: []string{"run", "--rm", "-i", "ghcr.io/github/github-mcp-server:latest"}, + }, + }, + shouldErr: false, + }, + { + name: "invalid node command for stdio server", + servers: map[string]*ServerConfig{ + "filesystem": { + Type: "stdio", + Command: "node", + Args: []string{"/path/to/server.js"}, + }, + }, + shouldErr: true, + errorMsg: "stdio servers must use containerized execution (command must be 'docker', got 'node')", + }, + { + name: "invalid python command for stdio server", + servers: map[string]*ServerConfig{ + "custom": { + Type: "stdio", + Command: "python", + Args: []string{"-m", "mcp_server"}, + }, + }, + shouldErr: true, + errorMsg: "stdio servers must use containerized execution (command must be 'docker', got 'python')", + }, + { + name: "invalid npx command with empty type (defaults to stdio)", + servers: map[string]*ServerConfig{ + "custom": { + Command: "npx", + Args: []string{"@modelcontextprotocol/server-everything"}, + }, + }, + shouldErr: true, + errorMsg: "stdio servers must use containerized execution (command must be 'docker', got 'npx')", + }, + { + name: "http server not affected by validation", + servers: map[string]*ServerConfig{ + "httpserver": { + Type: "http", + URL: "https://example.com/mcp", + }, + }, + shouldErr: false, + }, + { + name: "mixed valid Docker stdio and http servers", + servers: map[string]*ServerConfig{ + "github": { + Type: "stdio", + Command: "docker", + Args: []string{"run", "--rm", "-i", "ghcr.io/github/github-mcp-server:latest"}, + }, + "httpserver": { + Type: "http", + URL: "https://example.com/mcp", + }, + }, + shouldErr: false, + }, + { + name: "mixed Docker stdio, http, and invalid node stdio servers", + servers: map[string]*ServerConfig{ + "github": { + Type: "stdio", + Command: "docker", + Args: []string{"run", "--rm", "-i", "ghcr.io/github/github-mcp-server:latest"}, + }, + "httpserver": { + Type: "http", + URL: "https://example.com/mcp", + }, + "filesystem": { + Type: "stdio", + Command: "node", + Args: []string{"/path/to/server.js"}, + }, + }, + shouldErr: true, + errorMsg: "server 'filesystem': stdio servers must use containerized execution (command must be 'docker', got 'node')", + }, + { + name: "error message includes specification reference", + servers: map[string]*ServerConfig{ + "bad": { + Type: "stdio", + Command: "bash", + Args: []string{"script.sh"}, + }, + }, + shouldErr: true, + errorMsg: "MCP Gateway Specification Section 3.2.1", + }, + { + name: "error message includes specification URL", + servers: map[string]*ServerConfig{ + "bad": { + Type: "stdio", + Command: "go", + Args: []string{"run", "main.go"}, + }, + }, + shouldErr: true, + errorMsg: "https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/mcp-gateway.md#321-containerization-requirement", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateTOMLStdioContainerization(tt.servers) + + if tt.shouldErr { + require.Error(t, err) + if tt.errorMsg != "" { + assert.ErrorContains(t, err, tt.errorMsg) + } + } else { + assert.NoError(t, err) + } + }) + } +} + From 3adc1df6c08f974adc6b6be66655ccd0cbe7cd11 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:39:32 +0000 Subject: [PATCH 3/4] Update documentation to reflect Docker containerization requirement Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- AGENTS.md | 3 ++- README.md | 24 +++++++++++++++++------- config.example.toml | 3 ++- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1fbff713a..3df71f51b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,8 @@ args = ["run", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "-i", "ghcr.io/gith **Validation Features**: - Environment variable expansion: `${VAR_NAME}` (fails if undefined) - Required fields: `container` for stdio, `url` for http -- **Note**: The `command` field is not supported - stdio servers must use `container` +- **Containerization Requirement**: TOML stdio servers must use `command = "docker"` per [MCP Gateway Specification Section 3.2.1](https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/mcp-gateway.md#321-containerization-requirement) +- **Note**: In JSON stdin format, the `command` field is not supported - stdio servers must use `container` field - Port range validation: 1-65535 - Timeout validation: positive integers only diff --git a/README.md b/README.md index e33cad540..ece81a8a3 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ MCP Gateway supports two configuration formats: ### TOML Format (`config.toml`) -TOML configuration uses `command` and `args` fields directly for maximum flexibility: +TOML configuration requires `command = "docker"` for stdio-based MCP servers to ensure containerization: ```toml [servers] @@ -93,13 +93,23 @@ TOML configuration uses `command` and `args` fields directly for maximum flexibi [servers.github] command = "docker" args = ["run", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "-i", "ghcr.io/github/github-mcp-server:latest"] - -[servers.filesystem] -command = "node" -args = ["/path/to/filesystem-server.js"] ``` -**Note**: In TOML format, you specify the `command` and `args` directly. This allows you to use any command (docker, node, python, etc.). +**Important**: Per [MCP Gateway Specification Section 3.2.1](https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/mcp-gateway.md#321-containerization-requirement), all stdio-based MCP servers MUST be containerized. The gateway enforces this requirement by rejecting configurations where `command` is not `"docker"`. + +**Why containerization is required:** +- Provides necessary process isolation and security boundaries +- Enables reproducible environments across different deployment contexts +- Container images provide versioning and dependency management +- Ensures portability and consistent behavior + +For HTTP-based MCP servers, use the `url` field instead of `command`: + +```toml +[servers.myhttp] +type = "http" +url = "https://example.com/mcp" +``` ### JSON Stdin Format @@ -144,7 +154,7 @@ For the complete JSON configuration specification with all validation rules, see - **`container`** (required for stdio in JSON format): Docker container image (e.g., `"ghcr.io/github/github-mcp-server:latest"`) - Automatically wraps as `docker run --rm -i ` - **Note**: The `command` field is NOT supported in JSON stdin format (stdio servers must use `container` instead) - - **TOML format uses `command` and `args` fields directly** + - **TOML format uses `command` and `args` fields - `command` must be `"docker"` for stdio servers** - **`entrypoint`** (optional): Custom entrypoint for the container - Overrides the default container entrypoint diff --git a/config.example.toml b/config.example.toml index feb70b755..4595b6a40 100644 --- a/config.example.toml +++ b/config.example.toml @@ -42,7 +42,8 @@ tool_timeout = 120 # Example 1: GitHub MCP Server (stdio via Docker) [servers.github] -# Required: Docker command to run the container +# Required: Must be "docker" for stdio servers (MCP Gateway Specification Section 3.2.1) +# See: https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/mcp-gateway.md#321-containerization-requirement command = "docker" # Required: Arguments for the docker command From 44a08285e701173d7abfc2be4552f00e842d6c26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:51:02 +0000 Subject: [PATCH 4/4] Fix integration tests to use Docker commands for TOML configs Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- internal/config/validation_test.go | 1 - test/integration/binary_test.go | 24 +++++++++---------- test/integration/large_payload_test.go | 32 +++++++++++++------------- 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/internal/config/validation_test.go b/internal/config/validation_test.go index 89e68ba0e..e8ce5679e 100644 --- a/internal/config/validation_test.go +++ b/internal/config/validation_test.go @@ -875,4 +875,3 @@ func TestValidateTOMLStdioContainerization(t *testing.T) { }) } } - diff --git a/test/integration/binary_test.go b/test/integration/binary_test.go index 4bd5c39cf..d5163c4f3 100644 --- a/test/integration/binary_test.go +++ b/test/integration/binary_test.go @@ -30,8 +30,8 @@ func TestBinaryInvocation_RoutedMode(t *testing.T) { // Create a temporary config file configFile := createTempConfig(t, map[string]interface{}{ "testserver": map[string]interface{}{ - "command": "echo", - "args": []string{}, + "command": "docker", + "args": []string{"run", "--rm", "-i", "alpine:latest", "echo"}, }, }) defer os.Remove(configFile) @@ -130,12 +130,12 @@ func TestBinaryInvocation_UnifiedMode(t *testing.T) { configFile := createTempConfig(t, map[string]interface{}{ "backend1": map[string]interface{}{ - "command": "echo", - "args": []string{}, + "command": "docker", + "args": []string{"run", "--rm", "-i", "alpine:latest", "echo"}, }, "backend2": map[string]interface{}{ - "command": "echo", - "args": []string{}, + "command": "docker", + "args": []string{"run", "--rm", "-i", "alpine:latest", "echo"}, }, }) defer os.Remove(configFile) @@ -286,8 +286,8 @@ func TestBinaryInvocation_PipeOutput(t *testing.T) { // Create a simple config configFile := createTempConfig(t, map[string]interface{}{ "testserver": map[string]interface{}{ - "command": "echo", - "args": []string{}, + "command": "docker", + "args": []string{"run", "--rm", "-i", "alpine:latest", "echo"}, }, }) defer os.Remove(configFile) @@ -748,8 +748,8 @@ func TestBinaryInvocation_LogFileCreation(t *testing.T) { // Create a temporary config file configFile := createTempConfig(t, map[string]interface{}{ "testserver": map[string]interface{}{ - "command": "echo", - "args": []string{}, + "command": "docker", + "args": []string{"run", "--rm", "-i", "alpine:latest", "echo"}, }, }) defer os.Remove(configFile) @@ -896,8 +896,8 @@ func TestBinaryInvocation_LogDirEnvironmentVariable(t *testing.T) { // Create a temporary config file configFile := createTempConfig(t, map[string]interface{}{ "testserver": map[string]interface{}{ - "command": "echo", - "args": []string{}, + "command": "docker", + "args": []string{"run", "--rm", "-i", "alpine:latest", "echo"}, }, }) defer os.Remove(configFile) diff --git a/test/integration/large_payload_test.go b/test/integration/large_payload_test.go index 77a368cf4..19ad1b1c8 100644 --- a/test/integration/large_payload_test.go +++ b/test/integration/large_payload_test.go @@ -35,8 +35,8 @@ func TestLargePayload_StoredInPayloadDir(t *testing.T) { // Create a config file with payload_dir configured configFile := createTempConfigWithPayloadDir(t, payloadDir, map[string]interface{}{ "echo": map[string]interface{}{ - "command": "echo", - "args": []string{}, + "command": "docker", + "args": []string{"run", "--rm", "-i", "alpine:latest", "echo"}, }, }) defer os.Remove(configFile) @@ -98,8 +98,8 @@ func TestLargePayload_SessionIsolation(t *testing.T) { // Create a config file configFile := createTempConfigWithPayloadDir(t, payloadDir, map[string]interface{}{ "echo": map[string]interface{}{ - "command": "echo", - "args": []string{}, + "command": "docker", + "args": []string{"run", "--rm", "-i", "alpine:latest", "echo"}, }, }) defer os.Remove(configFile) @@ -177,8 +177,8 @@ func TestLargePayload_PayloadDirFlag(t *testing.T) { configFile := createTempConfig(t, map[string]interface{}{ "testserver": map[string]interface{}{ - "command": "echo", - "args": []string{}, + "command": "docker", + "args": []string{"run", "--rm", "-i", "alpine:latest", "echo"}, }, }) defer os.Remove(configFile) @@ -241,8 +241,8 @@ func TestLargePayload_ConfigPayloadDir(t *testing.T) { // Create a config file with payload_dir in gateway section configFile := createTempConfigWithPayloadDir(t, configPayloadDir, map[string]interface{}{ "testserver": map[string]interface{}{ - "command": "echo", - "args": []string{}, + "command": "docker", + "args": []string{"run", "--rm", "-i", "alpine:latest", "echo"}, }, }) defer os.Remove(configFile) @@ -302,8 +302,8 @@ func TestLargePayload_MultipleSessionsIsolated(t *testing.T) { configFile := createTempConfigWithPayloadDir(t, payloadDir, map[string]interface{}{ "echo": map[string]interface{}{ - "command": "echo", - "args": []string{}, + "command": "docker", + "args": []string{"run", "--rm", "-i", "alpine:latest", "echo"}, }, }) defer os.Remove(configFile) @@ -462,8 +462,8 @@ func TestLargePayload_PayloadDirectoryPermissions(t *testing.T) { configFile := createTempConfigWithPayloadDir(t, payloadDir, map[string]interface{}{ "echo": map[string]interface{}{ - "command": "echo", - "args": []string{}, + "command": "docker", + "args": []string{"run", "--rm", "-i", "alpine:latest", "echo"}, }, }) defer os.Remove(configFile) @@ -526,8 +526,8 @@ func TestLargePayload_SessionIDFromAuthorizationHeader(t *testing.T) { configFile := createTempConfigWithPayloadDir(t, payloadDir, map[string]interface{}{ "echo": map[string]interface{}{ - "command": "echo", - "args": []string{}, + "command": "docker", + "args": []string{"run", "--rm", "-i", "alpine:latest", "echo"}, }, }) defer os.Remove(configFile) @@ -618,8 +618,8 @@ func TestLargePayload_PayloadDirDoesNotExist(t *testing.T) { configFile := createTempConfig(t, map[string]interface{}{ "echo": map[string]interface{}{ - "command": "echo", - "args": []string{}, + "command": "docker", + "args": []string{"run", "--rm", "-i", "alpine:latest", "echo"}, }, }) defer os.Remove(configFile)