diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 00000000..b0de6c7c --- /dev/null +++ b/config.example.toml @@ -0,0 +1,142 @@ +# MCP Gateway Configuration Example +# This file demonstrates all available configuration options for the MCP Gateway. +# Copy this file to config.toml and customize for your needs. + +# ============================================================================ +# Gateway Configuration (Optional) +# ============================================================================ +# Gateway-level settings that apply to the entire server. + +[gateway] +# Port number for the HTTP server (default: 3000) +# Valid range: 1-65535 +port = 3000 + +# API key for authentication (optional) +# When set, clients must provide this key in the Authorization header +# Format: Authorization: +api_key = "" + +# Domain name for the gateway (optional) +# Used for CORS and other domain-specific features +domain = "" + +# Timeout for backend MCP server startup in seconds (default: 60) +# How long to wait for an MCP server to start before timing out +startup_timeout = 60 + +# Timeout for tool execution in seconds (default: 120) +# How long to wait for a tool call to complete before timing out +tool_timeout = 120 + +# ============================================================================ +# MCP Server Configurations +# ============================================================================ +# Define one or more MCP servers that the gateway will proxy to. +# Each server requires a unique name (e.g., [servers.github], [servers.memory]) + +# Example 1: GitHub MCP Server (stdio via Docker) +[servers.github] +# Required: Docker command to run the container +command = "docker" + +# Required: Arguments for the docker command +# Standard pattern: ["run", "--rm", "-i", , ] +args = [ + "run", + "--rm", # Remove container after exit + "-i", # Interactive mode for stdin/stdout + "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", # Pass through env var from host + "-e", "NO_COLOR=1", # Disable color output + "-e", "TERM=dumb", # Set terminal to dumb mode + "ghcr.io/github/github-mcp-server:latest" # Container image +] + +# Optional: Environment variables for the MCP server +# Empty string "" means pass through from host environment +# Non-empty string means set explicit value +[servers.github.env] +GITHUB_PERSONAL_ACCESS_TOKEN = "" # Pass through from host +# DEBUG = "true" # Explicit value example + +# Optional: Tool filtering +# tools = ["*"] # Allow all tools (default) +# tools = ["read_file", "list_files"] # Allow specific tools only + +# Example 2: Memory MCP Server (stdio via Docker) +[servers.memory] +command = "docker" +args = [ + "run", + "--rm", + "-i", + "-e", "NO_COLOR=1", + "-e", "TERM=dumb", + "-e", "PYTHONUNBUFFERED=1", # Python-specific: disable buffering + "mcp/memory" +] + +# Example 3: Custom MCP Server with Volume Mounts +[servers.custom] +command = "docker" +args = [ + "run", + "--rm", + "-i", + "-v", "${PWD}:/workspace:ro", # Mount current directory (read-only) + "-e", "NO_COLOR=1", + "-e", "TERM=dumb", + "custom/mcp-server:latest" +] + +# Example 4: HTTP-based MCP Server (not implemented yet) +# [servers.http-example] +# type = "http" +# url = "https://example.com/mcp" +# +# Optional: HTTP headers for authentication +# [servers.http-example.headers] +# Authorization = "Bearer token123" +# X-API-Key = "api-key-123" + +# ============================================================================ +# Advanced Options +# ============================================================================ + +# Enable Data Information Flow Control (DIFC) security model (default: false) +# When true, requires sys___init call before tool access +# This is an experimental feature - keep disabled for standard MCP compatibility +# enable_difc = false + +# ============================================================================ +# Notes +# ============================================================================ +# +# Server Types: +# - "stdio": Process-based MCP server communicating via stdin/stdout (default) +# - "http": HTTP-based MCP server (not yet implemented) +# +# Docker Best Practices: +# - Always use --rm to clean up containers after exit +# - Use -i for interactive mode (required for stdin/stdout communication) +# - Set NO_COLOR=1 and TERM=dumb for better compatibility +# - Use ${VAR_NAME} syntax to reference environment variables +# +# Environment Variables: +# - Empty value ("") passes through from host environment +# - Non-empty value sets explicit value in container +# - Undefined host variables will cause startup errors +# +# Tool Filtering: +# - ["*"] allows all tools (default behavior) +# - ["tool1", "tool2"] allows only specified tools +# - Useful for security and limiting server capabilities +# +# Configuration Validation: +# - Required fields: command, args (for stdio servers) +# - Unknown keys will generate warnings in logs +# - Parse errors will show line numbers for easy debugging +# +# For more information, see: +# - MCP Protocol: https://github.com/modelcontextprotocol +# - Documentation: README.md in this repository diff --git a/go.mod b/go.mod index b09bf8ee..8a8952bf 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/githubnext/gh-aw-mcpg go 1.25.0 require ( - github.com/BurntSushi/toml v1.5.0 + github.com/BurntSushi/toml v1.6.0 github.com/modelcontextprotocol/go-sdk v1.1.0 github.com/spf13/cobra v1.10.2 golang.org/x/term v0.38.0 diff --git a/go.sum b/go.sum index a0dd3b96..76b6e1f9 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/config/config.go b/internal/config/config.go index 9e51f180..3f32d1b7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,31 +16,31 @@ var logConfig = logger.New("config:config") // Config represents the MCPG configuration type Config struct { Servers map[string]*ServerConfig `toml:"servers"` - EnableDIFC bool // When true, enables DIFC enforcement and requires sys___init call before tool access. Default is false for standard MCP client compatibility. - Gateway *GatewayConfig // Gateway configuration (port, API key, etc.) + EnableDIFC bool `toml:"enable_difc"` // When true, enables DIFC enforcement and requires sys___init call before tool access. Default is false for standard MCP client compatibility. + Gateway *GatewayConfig `toml:"gateway"` // Gateway configuration (port, API key, etc.) } // GatewayConfig represents gateway-level configuration type GatewayConfig struct { - Port int - APIKey string - Domain string - StartupTimeout int // Seconds - ToolTimeout int // Seconds + Port int `toml:"port"` + APIKey string `toml:"api_key"` + Domain string `toml:"domain"` + StartupTimeout int `toml:"startup_timeout"` // Seconds + ToolTimeout int `toml:"tool_timeout"` // Seconds } // ServerConfig represents a single MCP server configuration type ServerConfig struct { - Type string // "stdio" | "http" + Type string `toml:"type"` // "stdio" | "http" Command string `toml:"command"` Args []string `toml:"args"` Env map[string]string `toml:"env"` WorkingDirectory string `toml:"working_directory"` // HTTP-specific fields - URL string // HTTP endpoint URL - Headers map[string]string // HTTP headers for authentication + URL string `toml:"url"` // HTTP endpoint URL + Headers map[string]string `toml:"headers"` // HTTP headers for authentication // Tool filtering (applies to both stdio and http servers) - Tools []string // Tool filter: ["*"] for all tools, or list of specific tool names + Tools []string `toml:"tools"` // Tool filter: ["*"] for all tools, or list of specific tool names } // StdinConfig represents JSON configuration from stdin @@ -77,9 +77,22 @@ type StdinGatewayConfig struct { func LoadFromFile(path string) (*Config, error) { logConfig.Printf("Loading configuration from file: path=%s", path) var cfg Config - if _, err := toml.DecodeFile(path, &cfg); err != nil { + meta, err := toml.DecodeFile(path, &cfg) + if err != nil { + // Check if it's a ParseError to provide line numbers + if pErr, ok := err.(toml.ParseError); ok { + return nil, fmt.Errorf("TOML parse error at line %d: %w", pErr.Position.Line, err) + } return nil, fmt.Errorf("failed to decode TOML: %w", err) } + + // Check for unknown/undecoded keys (typo detection) + if len(meta.Undecoded()) > 0 { + logConfig.Printf("Warning: unknown configuration keys detected: %v", meta.Undecoded()) + // For now, just warn - we could make this strict with a flag later + // return nil, fmt.Errorf("unknown configuration keys: %v", meta.Undecoded()) + } + logConfig.Printf("Successfully loaded %d servers from TOML file", len(cfg.Servers)) return &cfg, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d56dce49..33e8ae9a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "strings" "testing" @@ -852,3 +853,160 @@ func TestLoadFromStdin_InvalidMountFormat(t *testing.T) { }) } } + +// Tests for LoadFromFile function with TOML files + +func TestLoadFromFile_ValidTOML(t *testing.T) { + // Create a temporary TOML file + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "config.toml") + + tomlContent := ` +[servers.test] +command = "docker" +args = ["run", "--rm", "-i", "test/container:latest"] + +[servers.test.env] +TEST_VAR = "value" +` + + err := os.WriteFile(tmpFile, []byte(tomlContent), 0644) + require.NoError(t, err, "Failed to write temp TOML file") + + cfg, err := LoadFromFile(tmpFile) + require.NoError(t, err, "LoadFromFile() failed") + require.NotNil(t, cfg, "LoadFromFile() returned nil config") + + assert.Len(t, cfg.Servers, 1, "Expected 1 server") + server, ok := cfg.Servers["test"] + require.True(t, ok, "Server 'test' not found") + assert.Equal(t, "docker", server.Command) + assert.Equal(t, []string{"run", "--rm", "-i", "test/container:latest"}, server.Args) + assert.Equal(t, "value", server.Env["TEST_VAR"]) +} + +func TestLoadFromFile_WithGatewayConfig(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "config.toml") + + tomlContent := ` +[gateway] +port = 8080 +api_key = "test-key-123" +domain = "localhost" +startup_timeout = 30 +tool_timeout = 60 + +[servers.test] +command = "docker" +args = ["run", "--rm", "-i", "test/container:latest"] +` + + err := os.WriteFile(tmpFile, []byte(tomlContent), 0644) + require.NoError(t, err, "Failed to write temp TOML file") + + cfg, err := LoadFromFile(tmpFile) + require.NoError(t, err, "LoadFromFile() failed") + require.NotNil(t, cfg, "LoadFromFile() returned nil config") + require.NotNil(t, cfg.Gateway, "Gateway config should not be nil") + + assert.Equal(t, 8080, cfg.Gateway.Port) + assert.Equal(t, "test-key-123", cfg.Gateway.APIKey) + assert.Equal(t, "localhost", cfg.Gateway.Domain) + assert.Equal(t, 30, cfg.Gateway.StartupTimeout) + assert.Equal(t, 60, cfg.Gateway.ToolTimeout) +} + +func TestLoadFromFile_InvalidTOMLWithLineNumber(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "config.toml") + + // Invalid TOML: unterminated string on line 2 + tomlContent := `[servers.test] +command = "docker +args = ["run"] +` + + err := os.WriteFile(tmpFile, []byte(tomlContent), 0644) + require.NoError(t, err, "Failed to write temp TOML file") + + cfg, err := LoadFromFile(tmpFile) + require.Error(t, err, "Expected error for invalid TOML") + assert.Nil(t, cfg, "Config should be nil on error") + + // Error should contain line number information + assert.Contains(t, err.Error(), "line", "Error should mention line number") +} + +func TestLoadFromFile_UnknownKeys(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "config.toml") + + // TOML with unknown key "unknown_field" + tomlContent := ` +[servers.test] +command = "docker" +args = ["run", "--rm", "-i", "test/container:latest"] +unknown_field = "should trigger warning" +` + + err := os.WriteFile(tmpFile, []byte(tomlContent), 0644) + require.NoError(t, err, "Failed to write temp TOML file") + + // Should still load successfully but log warning + cfg, err := LoadFromFile(tmpFile) + require.NoError(t, err, "LoadFromFile() should succeed with unknown keys") + require.NotNil(t, cfg, "Config should not be nil") +} + +func TestLoadFromFile_NonExistentFile(t *testing.T) { + cfg, err := LoadFromFile("/nonexistent/path/config.toml") + require.Error(t, err, "Expected error for nonexistent file") + assert.Nil(t, cfg, "Config should be nil on error") +} + +func TestLoadFromFile_EmptyFile(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "empty.toml") + + err := os.WriteFile(tmpFile, []byte(""), 0644) + require.NoError(t, err, "Failed to write temp TOML file") + + cfg, err := LoadFromFile(tmpFile) + require.NoError(t, err, "LoadFromFile() should succeed with empty file") + require.NotNil(t, cfg, "Config should not be nil") + assert.Empty(t, cfg.Servers, "Servers should be empty") +} + +func TestLoadFromFile_MultipleServers(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "config.toml") + + tomlContent := ` +[servers.github] +command = "docker" +args = ["run", "--rm", "-i", "ghcr.io/github/github-mcp-server:latest"] + +[servers.github.env] +GITHUB_TOKEN = "" + +[servers.memory] +command = "docker" +args = ["run", "--rm", "-i", "mcp/memory"] +` + + err := os.WriteFile(tmpFile, []byte(tomlContent), 0644) + require.NoError(t, err, "Failed to write temp TOML file") + + cfg, err := LoadFromFile(tmpFile) + require.NoError(t, err, "LoadFromFile() failed") + require.NotNil(t, cfg, "LoadFromFile() returned nil config") + + assert.Len(t, cfg.Servers, 2, "Expected 2 servers") + + _, ok := cfg.Servers["github"] + assert.True(t, ok, "Server 'github' not found") + + _, ok = cfg.Servers["memory"] + assert.True(t, ok, "Server 'memory' not found") +}