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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 17 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,21 +85,31 @@ 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]

[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

Expand Down Expand Up @@ -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 <container>`
- **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
Expand Down
3 changes: 2 additions & 1 deletion config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions internal/config/config_core.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
26 changes: 26 additions & 0 deletions internal/config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Comment on lines +271 to +285

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateTOMLStdioContainerization only enforces the Docker requirement when cfg.Type is "", "stdio", or "local". However, the launcher treats any non-"http" type as a stdio server, so a TOML config can bypass this check by setting type = "HTTP" (case mismatch) or any unknown value and using a non-docker command. Consider normalizing Type (e.g., strings.ToLower/TrimSpace) and enforcing Docker for all non-HTTP servers (or explicitly rejecting unsupported type values in TOML).

Copilot uses AI. Check for mistakes.
}

logValidation.Print("TOML stdio containerization validation passed")
return nil
}
163 changes: 163 additions & 0 deletions internal/config/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -712,3 +712,166 @@ 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,
},
Comment on lines +716 to +755

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new test suite doesn’t cover bypass cases like Type: "HTTP" (case mismatch) or an unknown Type value. Given the launcher treats any non-"http" type as stdio, add test coverage ensuring TOML validation still enforces Docker (or rejects unsupported types) for these inputs.

Copilot uses AI. Check for mistakes.
{
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)
}
})
}
}
24 changes: 12 additions & 12 deletions test/integration/binary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
Comment on lines 31 to 35

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These integration tests now depend on Docker and on pulling/running alpine:latest, but the test doesn’t check Docker availability (other integration tests skip when docker version fails). Add a Docker availability check (and ideally use a pinned image tag/digest instead of alpine:latest to reduce flakiness).

Copilot uses AI. Check for mistakes.
})
defer os.Remove(configFile)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading