diff --git a/internal/config/config_stdin.go b/internal/config/config_stdin.go index cbc90b84..00c3d12c 100644 --- a/internal/config/config_stdin.go +++ b/internal/config/config_stdin.go @@ -49,8 +49,9 @@ type StdinOpenTelemetryConfig struct { // Endpoint is the OTLP/HTTP collector URL. MUST be HTTPS. Supports ${VAR} expansion. Endpoint string `json:"endpoint"` - // Headers are HTTP headers for export requests (e.g. auth tokens). Values support ${VAR}. - Headers map[string]string `json:"headers,omitempty"` + // Headers is a comma-separated list of key=value HTTP headers for export requests + // (e.g. "Authorization=Bearer ${OTEL_TOKEN},X-Custom=value"). Supports ${VAR} expansion. + Headers string `json:"headers,omitempty"` // TraceID is the parent trace ID (32-char lowercase hex, W3C format). Supports ${VAR}. TraceID string `json:"traceId,omitempty"` diff --git a/internal/config/config_tracing.go b/internal/config/config_tracing.go index a6ad6832..3a4a815e 100644 --- a/internal/config/config_tracing.go +++ b/internal/config/config_tracing.go @@ -22,18 +22,17 @@ const DefaultTracingServiceName = "mcp-gateway" // service_name = "mcp-gateway" // trace_id = "4bf92f3577b34da6a3ce929d0e0e4736" // span_id = "00f067aa0ba902b7" -// -// [gateway.opentelemetry.headers] -// Authorization = "Bearer ${OTEL_TOKEN}" +// headers = "Authorization=Bearer ${OTEL_TOKEN}" type TracingConfig struct { // Endpoint is the OTLP HTTP endpoint to export traces to. // When using the opentelemetry section (spec §4.1.3.6), this MUST be an HTTPS URL. // If empty, tracing is disabled and a noop tracer is used. Endpoint string `toml:"endpoint" json:"endpoint,omitempty"` - // Headers are HTTP headers sent with every OTLP export request (e.g. auth tokens). - // Header values support ${VAR} variable expansion (expanded at config load time). - Headers map[string]string `toml:"headers" json:"headers,omitempty"` + // Headers is a comma-separated list of key=value HTTP headers sent with every OTLP + // export request (e.g. "Authorization=Bearer ${OTEL_TOKEN},X-Custom=value"). + // Supports ${VAR} variable expansion (expanded at config load time). + Headers string `toml:"headers" json:"headers,omitempty"` // TraceID is an optional W3C trace ID (32-char lowercase hex) used to construct the // parent traceparent header, linking gateway spans into a pre-existing trace. diff --git a/internal/config/config_tracing_test.go b/internal/config/config_tracing_test.go index cebf1147..47ed0380 100644 --- a/internal/config/config_tracing_test.go +++ b/internal/config/config_tracing_test.go @@ -62,7 +62,7 @@ func TestOTEL004_NonHTTPSEndpoint_Error(t *testing.T) { // T-OTEL-005: TracingConfig struct carries all required spec §4.1.3.6 fields. func TestOTEL005_TracingConfigFields(t *testing.T) { - headers := map[string]string{"Authorization": "Bearer token"} + headers := "Authorization=Bearer token" cfg := &TracingConfig{ Endpoint: "https://otel-collector.example.com", Headers: headers, @@ -80,10 +80,7 @@ func TestOTEL005_TracingConfigFields(t *testing.T) { // T-OTEL-006: Headers are preserved in TracingConfig when configured. func TestOTEL006_HeadersPreserved(t *testing.T) { - headers := map[string]string{ - "Authorization": "Bearer my-token", - "X-Custom": "value", - } + headers := "Authorization=Bearer my-token,X-Custom=value" cfg := &TracingConfig{ Endpoint: "https://otel-collector.example.com", Headers: headers, @@ -206,7 +203,7 @@ func TestExpandTracingVariables(t *testing.T) { Endpoint: "${TEST_OTEL_ENDPOINT}", TraceID: "${TEST_TRACE_ID}", SpanID: "${TEST_SPAN_ID}", - Headers: map[string]string{"Authorization": "${TEST_AUTH_TOKEN}"}, + Headers: "Authorization=${TEST_AUTH_TOKEN}", } err := expandTracingVariables(cfg) @@ -215,7 +212,7 @@ func TestExpandTracingVariables(t *testing.T) { assert.Equal(t, "https://otel.example.com", cfg.Endpoint) assert.Equal(t, "4bf92f3577b34da6a3ce929d0e0e4736", cfg.TraceID) assert.Equal(t, "00f067aa0ba902b7", cfg.SpanID) - assert.Equal(t, "Bearer secret-token", cfg.Headers["Authorization"]) + assert.Equal(t, "Authorization=Bearer secret-token", cfg.Headers) // After expansion, validation should pass err = validateOpenTelemetryConfig(cfg, true) @@ -262,7 +259,7 @@ func TestGetSampleRate_NewFields(t *testing.T) { rate := 0.5 cfg := &TracingConfig{ Endpoint: "https://otel-collector.example.com", - Headers: map[string]string{"Authorization": "Bearer tok"}, + Headers: "Authorization=Bearer tok", TraceID: "4bf92f3577b34da6a3ce929d0e0e4736", SpanID: "00f067aa0ba902b7", ServiceName: "my-service", diff --git a/internal/config/schema/mcp-gateway-config.schema.json b/internal/config/schema/mcp-gateway-config.schema.json index 43afc480..754024c1 100644 --- a/internal/config/schema/mcp-gateway-config.schema.json +++ b/internal/config/schema/mcp-gateway-config.schema.json @@ -1,420 +1,418 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://docs.github.com/gh-aw/schemas/mcp-gateway-config.schema.json", - "title": "MCP Gateway Configuration", - "description": "Configuration schema for the Model Context Protocol (MCP) Gateway. The gateway provides transparent HTTP access to multiple MCP servers with protocol translation, server isolation, and authentication capabilities.", - "type": "object", - "properties": { - "mcpServers": { - "type": "object", - "description": "Map of MCP server configurations. Each key is a unique server identifier, and the value is the server configuration.", - "additionalProperties": { - "$ref": "#/definitions/mcpServerConfig" + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://docs.github.com/gh-aw/schemas/mcp-gateway-config.schema.json", + "title": "MCP Gateway Configuration", + "description": "Configuration schema for the Model Context Protocol (MCP) Gateway. The gateway provides transparent HTTP access to multiple MCP servers with protocol translation, server isolation, and authentication capabilities.", + "type": "object", + "properties": { + "mcpServers": { + "type": "object", + "description": "Map of MCP server configurations. Each key is a unique server identifier, and the value is the server configuration.", + "additionalProperties": { + "$ref": "#/definitions/mcpServerConfig" + } + }, + "gateway": { + "$ref": "#/definitions/gatewayConfig", + "description": "Gateway-specific configuration for the MCP Gateway service." + }, + "customSchemas": { + "type": "object", + "description": "Map of custom server type names to JSON Schema URLs for validation. Custom types enable extensibility for specialized MCP server implementations. Keys are type names (must not be 'stdio' or 'http'), values are HTTPS URLs pointing to JSON Schema definitions, or empty strings to skip validation.", + "patternProperties": { + "^(?!stdio$|http$)[a-z][a-z0-9-]*$": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "pattern": "^https://.+" + }, + { + "type": "string", + "enum": [ + "" + ] } + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "mcpServers", + "gateway" + ], + "additionalProperties": false, + "definitions": { + "mcpServerConfig": { + "type": "object", + "description": "Configuration for an individual MCP server. Supports stdio servers, HTTP servers, and custom server types registered via customSchemas. Per MCP Gateway Specification section 4.1.4, custom types enable extensibility for specialized MCP server implementations.", + "oneOf": [ + { + "$ref": "#/definitions/stdioServerConfig" }, - "gateway": { - "$ref": "#/definitions/gatewayConfig", - "description": "Gateway-specific configuration for the MCP Gateway service." + { + "$ref": "#/definitions/httpServerConfig" }, - "customSchemas": { - "type": "object", - "description": "Map of custom server type names to JSON Schema URLs for validation. Custom types enable extensibility for specialized MCP server implementations. Keys are type names (must not be 'stdio' or 'http'), values are HTTPS URLs pointing to JSON Schema definitions, or empty strings to skip validation.", - "patternProperties": { - "^(?!stdio$|http$)[a-z][a-z0-9-]*$": { - "oneOf": [ - { - "type": "string", - "format": "uri", - "pattern": "^https://.+" - }, - { - "type": "string", - "enum": [ - "" - ] - } - ] - } - }, - "additionalProperties": false + { + "$ref": "#/definitions/customServerConfig" } + ] }, - "required": [ - "mcpServers", - "gateway" - ], - "additionalProperties": false, - "definitions": { - "mcpServerConfig": { - "type": "object", - "description": "Configuration for an individual MCP server. Supports stdio servers, HTTP servers, and custom server types registered via customSchemas. Per MCP Gateway Specification section 4.1.4, custom types enable extensibility for specialized MCP server implementations.", - "oneOf": [ - { - "$ref": "#/definitions/stdioServerConfig" - }, - { - "$ref": "#/definitions/httpServerConfig" - }, - { - "$ref": "#/definitions/customServerConfig" - } - ] + "stdioServerConfig": { + "type": "object", + "description": "Configuration for a containerized stdio-based MCP server. The gateway communicates with the server via standard input/output streams. Per MCP Gateway Specification section 3.2.1, all stdio servers MUST be containerized - direct command execution is not supported.", + "properties": { + "type": { + "type": "string", + "enum": [ + "stdio" + ], + "description": "Transport type for the MCP server. For containerized servers, use 'stdio'.", + "default": "stdio" }, - "stdioServerConfig": { - "type": "object", - "description": "Configuration for a containerized stdio-based MCP server. The gateway communicates with the server via standard input/output streams. Per MCP Gateway Specification section 3.2.1, all stdio servers MUST be containerized - direct command execution is not supported.", - "properties": { - "type": { - "type": "string", - "enum": [ - "stdio" - ], - "description": "Transport type for the MCP server. For containerized servers, use 'stdio'.", - "default": "stdio" - }, - "container": { - "type": "string", - "description": "Container image for the MCP server (e.g., 'ghcr.io/example/mcp-server:latest'). This field is required for stdio servers per MCP Gateway Specification section 4.1.2.", - "minLength": 1, - "pattern": "^[a-zA-Z0-9][a-zA-Z0-9./_-]*(:([a-zA-Z0-9._-]+|latest))?$" - }, - "entrypoint": { - "type": "string", - "description": "Optional entrypoint override for the container, equivalent to 'docker run --entrypoint'. If not specified, the container's default entrypoint is used.", - "minLength": 1 - }, - "entrypointArgs": { - "type": "array", - "description": "Arguments passed to the container entrypoint. These are executed inside the container after the entrypoint command.", - "items": { - "type": "string" - }, - "default": [] - }, - "mounts": { - "type": "array", - "description": "Volume mounts for the container. Format: 'source:dest' or 'source:dest:mode' where mode is 'ro' (read-only) or 'rw' (read-write). Example: '/host/data:/container/data:ro'", - "items": { - "type": "string", - "pattern": "^[^:]+:[^:]+(:(ro|rw))?$" - }, - "default": [] - }, - "env": { - "type": "object", - "description": "Environment variables for the server process. Values may contain variable expressions using '${VARIABLE_NAME}' syntax, which will be resolved from the process environment.", - "additionalProperties": { - "type": "string" - }, - "default": {} - }, - "args": { - "type": "array", - "description": "Additional Docker runtime arguments passed before the container image (e.g., '--network', 'host').", - "items": { - "type": "string" - }, - "default": [] - }, - "tools": { - "type": "array", - "description": "Tool filter for the MCP server. Use ['*'] to allow all tools, or specify a list of tool names to allow. This field is passed through to agent configurations.", - "items": { - "type": "string" - }, - "default": [ - "*" - ] - } - }, - "required": [ - "container" - ], - "additionalProperties": false + "container": { + "type": "string", + "description": "Container image for the MCP server (e.g., 'ghcr.io/example/mcp-server:latest'). This field is required for stdio servers per MCP Gateway Specification section 4.1.2.", + "minLength": 1, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9./_-]*(:([a-zA-Z0-9._-]+|latest))?$" }, - "httpServerConfig": { - "type": "object", - "description": "Configuration for an HTTP-based MCP server. The gateway forwards requests directly to the specified HTTP endpoint.", - "properties": { - "type": { - "type": "string", - "enum": [ - "http" - ], - "description": "Transport type for the MCP server. For HTTP servers, use 'http'." - }, - "url": { - "type": "string", - "description": "HTTP endpoint URL for the MCP server (e.g., 'https://api.example.com/mcp'). This field is required for HTTP servers per MCP Gateway Specification section 4.1.2.", - "format": "uri", - "pattern": "^https?://.+", - "minLength": 1 - }, - "headers": { - "type": "object", - "description": "HTTP headers to include in requests to the external HTTP MCP server. Commonly used for authentication to the external server (e.g., Authorization: 'Bearer ${API_TOKEN}' for servers that require Bearer tokens). Note: This is for authenticating to external HTTP servers, not for gateway client authentication. Values may contain variable expressions using '${VARIABLE_NAME}' syntax.", - "additionalProperties": { - "type": "string" - }, - "default": {} - }, - "tools": { - "type": "array", - "description": "Tool filter for the MCP server. Use ['*'] to allow all tools, or specify a list of tool names to allow. This field is passed through to agent configurations.", - "items": { - "type": "string" - }, - "default": [ - "*" - ] - }, - "env": { - "type": "object", - "description": "Environment variables to pass through for variable resolution. Values may contain variable expressions using '${VARIABLE_NAME}' syntax, which will be resolved from the process environment.", - "additionalProperties": { - "type": "string" - }, - "default": {} - }, - "guard-policies": { - "type": "object", - "description": "Guard policies for access control at the MCP gateway level. The structure of guard policies is server-specific. For GitHub MCP server, see the GitHub guard policy schema. For other servers (Jira, WorkIQ), different policy schemas will apply.", - "additionalProperties": true - } - }, - "required": [ - "type", - "url" - ], - "additionalProperties": false + "entrypoint": { + "type": "string", + "description": "Optional entrypoint override for the container, equivalent to 'docker run --entrypoint'. If not specified, the container's default entrypoint is used.", + "minLength": 1 }, - "customServerConfig": { - "type": "object", - "description": "Configuration for a custom MCP server type. Custom types must be registered in customSchemas with a JSON Schema URL. The configuration is validated against the registered schema. Per MCP Gateway Specification section 4.1.4, this enables extensibility for specialized MCP server implementations.", - "properties": { - "type": { - "type": "string", - "pattern": "^(?!stdio$|http$)[a-z][a-z0-9-]*$", - "description": "Custom server type name. Must not be 'stdio' or 'http'. Must be registered in customSchemas." - } - }, - "required": [ - "type" - ], - "additionalProperties": true + "entrypointArgs": { + "type": "array", + "description": "Arguments passed to the container entrypoint. These are executed inside the container after the entrypoint command.", + "items": { + "type": "string" + }, + "default": [] }, - "gatewayConfig": { - "type": "object", - "description": "Gateway-specific configuration for the MCP Gateway service.", - "properties": { - "port": { - "oneOf": [ - { - "type": "integer", - "minimum": 1, - "maximum": 65535 - }, - { - "type": "string", - "pattern": "^\\$\\{[A-Z_][A-Z0-9_]*\\}$" - } - ], - "description": "HTTP server port for the gateway. The gateway exposes endpoints at http://{domain}:{port}/. Can be an integer (1-65535) or a variable expression like '${MCP_GATEWAY_PORT}'." - }, - "apiKey": { - "type": "string", - "description": "API key for authentication. When configured, clients must include 'Authorization: ' header in all RPC requests (the API key is used directly without Bearer or other scheme prefix). Per MCP Gateway Specification section 7.1, the authorization header format is 'Authorization: ' where the API key is the complete header value. API keys must not be logged in plaintext per section 7.2.", - "minLength": 1 - }, - "domain": { - "oneOf": [ - { - "type": "string", - "enum": [ - "localhost", - "host.docker.internal" - ] - }, - { - "type": "string", - "pattern": "^\\$\\{[A-Z_][A-Z0-9_]*\\}$" - } - ], - "description": "Gateway domain for constructing URLs. Use 'localhost' for local development or 'host.docker.internal' when the gateway runs in a container and needs to access the host. Can also be a variable expression like '${MCP_GATEWAY_DOMAIN}'." - }, - "startupTimeout": { - "type": "integer", - "description": "Server startup timeout in seconds. The gateway enforces this timeout when initializing containerized stdio servers.", - "minimum": 1, - "default": 30 - }, - "toolTimeout": { - "type": "integer", - "description": "Tool invocation timeout in seconds. The gateway enforces this timeout for individual tool/method calls to MCP servers.", - "minimum": 1, - "default": 60 - }, - "payloadDir": { - "type": "string", - "description": "Directory path for storing large payload JSON files for authenticated clients. MUST be an absolute path: Unix paths start with '/', Windows paths start with a drive letter followed by ':\\'. Relative paths, empty strings, and paths that don't follow these conventions are not allowed.", - "minLength": 1, - "pattern": "^(/|[A-Za-z]:\\\\)" - }, - "payloadSizeThreshold": { - "type": "integer", - "description": "Size threshold in bytes for writing payloads to files instead of inlining them in the response. Payloads larger than this threshold are written to files in payloadDir. Defaults to 524288 (512KB) if not specified.", - "minimum": 1 - }, - "payloadPathPrefix": { - "type": "string", - "description": "Optional path prefix for payload file paths as seen from within agent containers. Use this when the payload directory is mounted at a different path inside the container than on the host.", - "minLength": 1 - }, - "trustedBots": { - "type": "array", - "description": "Additional trusted bot identity strings passed to the gateway and merged with its built-in internal trusted identity list. This field is additive and cannot remove entries from the gateway's built-in list. Typically GitHub bot usernames such as 'github-actions[bot]' or 'copilot-swe-agent[bot]'.", - "items": { - "type": "string", - "minLength": 1 - }, - "minItems": 1 - }, - "opentelemetry": { - "type": "object", - "description": "OpenTelemetry OTLP tracing configuration (spec \u00a74.1.3.6). When present, endpoint is required and MUST be HTTPS. Enables distributed tracing of MCP tool invocations.", - "properties": { - "endpoint": { - "type": "string", - "description": "OTLP/HTTP collector URL. MUST use HTTPS (e.g., \"https://otel-collector.example.com\"). Supports ${VAR} expansion.", - "minLength": 1, - "pattern": "^(https://.+|\\$\\{[A-Za-z_][A-Za-z0-9_]*\\})$" - }, - "headers": { - "type": "object", - "description": "HTTP headers sent with every OTLP export request (e.g. authentication tokens). Values support ${VAR} expansion.", - "additionalProperties": { - "type": "string" - } - }, - "traceId": { - "type": "string", - "description": "Parent trace ID in W3C format (32-char lowercase hex). Used to link gateway spans into a pre-existing trace. Supports ${VAR} expansion.", - "pattern": "^([0-9a-f]{32}|\\$\\{[A-Za-z_][A-Za-z0-9_]*\\})$" - }, - "spanId": { - "type": "string", - "description": "Parent span ID in W3C format (16-char lowercase hex). Paired with traceId to construct the traceparent header. Ignored when traceId is absent. Supports ${VAR} expansion.", - "pattern": "^([0-9a-f]{16}|\\$\\{[A-Za-z_][A-Za-z0-9_]*\\})$" - }, - "serviceName": { - "type": "string", - "description": "service.name resource attribute for all emitted spans. Defaults to \"mcp-gateway\".", - "minLength": 1 - } - }, - "required": [ - "endpoint" - ], - "additionalProperties": false - } - }, - "required": [ - "port", - "domain", - "apiKey" - ], - "additionalProperties": false + "mounts": { + "type": "array", + "description": "Volume mounts for the container. Format: 'source:dest' or 'source:dest:mode' where mode is 'ro' (read-only) or 'rw' (read-write). Example: '/host/data:/container/data:ro'", + "items": { + "type": "string", + "pattern": "^[^:]+:[^:]+(:(ro|rw))?$" + }, + "default": [] + }, + "env": { + "type": "object", + "description": "Environment variables for the server process. Values may contain variable expressions using '${VARIABLE_NAME}' syntax, which will be resolved from the process environment.", + "additionalProperties": { + "type": "string" + }, + "default": {} + }, + "args": { + "type": "array", + "description": "Additional Docker runtime arguments passed before the container image (e.g., '--network', 'host').", + "items": { + "type": "string" + }, + "default": [] + }, + "tools": { + "type": "array", + "description": "Tool filter for the MCP server. Use ['*'] to allow all tools, or specify a list of tool names to allow. This field is passed through to agent configurations.", + "items": { + "type": "string" + }, + "default": [ + "*" + ] } + }, + "required": [ + "container" + ], + "additionalProperties": false }, - "examples": [ - { - "mcpServers": { - "github": { - "container": "ghcr.io/github/github-mcp-server:latest", - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" - } - } + "httpServerConfig": { + "type": "object", + "description": "Configuration for an HTTP-based MCP server. The gateway forwards requests directly to the specified HTTP endpoint.", + "properties": { + "type": { + "type": "string", + "enum": [ + "http" + ], + "description": "Transport type for the MCP server. For HTTP servers, use 'http'." + }, + "url": { + "type": "string", + "description": "HTTP endpoint URL for the MCP server (e.g., 'https://api.example.com/mcp'). This field is required for HTTP servers per MCP Gateway Specification section 4.1.2.", + "format": "uri", + "pattern": "^https?://.+", + "minLength": 1 + }, + "headers": { + "type": "object", + "description": "HTTP headers to include in requests to the external HTTP MCP server. Commonly used for authentication to the external server (e.g., Authorization: 'Bearer ${API_TOKEN}' for servers that require Bearer tokens). Note: This is for authenticating to external HTTP servers, not for gateway client authentication. Values may contain variable expressions using '${VARIABLE_NAME}' syntax.", + "additionalProperties": { + "type": "string" + }, + "default": {} + }, + "tools": { + "type": "array", + "description": "Tool filter for the MCP server. Use ['*'] to allow all tools, or specify a list of tool names to allow. This field is passed through to agent configurations.", + "items": { + "type": "string" + }, + "default": [ + "*" + ] + }, + "env": { + "type": "object", + "description": "Environment variables to pass through for variable resolution. Values may contain variable expressions using '${VARIABLE_NAME}' syntax, which will be resolved from the process environment.", + "additionalProperties": { + "type": "string" + }, + "default": {} + }, + "guard-policies": { + "type": "object", + "description": "Guard policies for access control at the MCP gateway level. The structure of guard policies is server-specific. For GitHub MCP server, see the GitHub guard policy schema. For other servers (Jira, WorkIQ), different policy schemas will apply.", + "additionalProperties": true + } + }, + "required": [ + "type", + "url" + ], + "additionalProperties": false + }, + "customServerConfig": { + "type": "object", + "description": "Configuration for a custom MCP server type. Custom types must be registered in customSchemas with a JSON Schema URL. The configuration is validated against the registered schema. Per MCP Gateway Specification section 4.1.4, this enables extensibility for specialized MCP server implementations.", + "properties": { + "type": { + "type": "string", + "pattern": "^(?!stdio$|http$)[a-z][a-z0-9-]*$", + "description": "Custom server type name. Must not be 'stdio' or 'http'. Must be registered in customSchemas." + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + "gatewayConfig": { + "type": "object", + "description": "Gateway-specific configuration for the MCP Gateway service.", + "properties": { + "port": { + "oneOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 65535 }, - "gateway": { - "port": 8080, - "domain": "localhost", - "apiKey": "gateway-secret-token" + { + "type": "string", + "pattern": "^\\$\\{[A-Z_][A-Z0-9_]*\\}$" } + ], + "description": "HTTP server port for the gateway. The gateway exposes endpoints at http://{domain}:{port}/. Can be an integer (1-65535) or a variable expression like '${MCP_GATEWAY_PORT}'." }, - { - "mcpServers": { - "data-server": { - "container": "ghcr.io/example/data-mcp:latest", - "entrypoint": "/custom/entrypoint.sh", - "entrypointArgs": [ - "--config", - "/app/config.json" - ], - "mounts": [ - "/host/data:/container/data:ro", - "/host/config:/container/config:rw" - ], - "type": "stdio" - } + "apiKey": { + "type": "string", + "description": "API key for authentication. When configured, clients must include 'Authorization: ' header in all RPC requests (the API key is used directly without Bearer or other scheme prefix). Per MCP Gateway Specification section 7.1, the authorization header format is 'Authorization: ' where the API key is the complete header value. API keys must not be logged in plaintext per section 7.2.", + "minLength": 1 + }, + "domain": { + "oneOf": [ + { + "type": "string", + "enum": [ + "localhost", + "host.docker.internal" + ] }, - "gateway": { - "port": 8080, - "domain": "localhost", - "startupTimeout": 60, - "toolTimeout": 120 + { + "type": "string", + "pattern": "^\\$\\{[A-Z_][A-Z0-9_]*\\}$" } + ], + "description": "Gateway domain for constructing URLs. Use 'localhost' for local development or 'host.docker.internal' when the gateway runs in a container and needs to access the host. Can also be a variable expression like '${MCP_GATEWAY_DOMAIN}'." }, - { - "mcpServers": { - "local-server": { - "container": "ghcr.io/example/python-mcp:latest", - "entrypointArgs": [ - "--config", - "/app/config.json" - ], - "type": "stdio" - }, - "remote-server": { - "type": "http", - "url": "https://api.example.com/mcp", - "headers": { - "Authorization": "Bearer ${API_TOKEN}" - } - } + "startupTimeout": { + "type": "integer", + "description": "Server startup timeout in seconds. The gateway enforces this timeout when initializing containerized stdio servers.", + "minimum": 1, + "default": 30 + }, + "toolTimeout": { + "type": "integer", + "description": "Tool invocation timeout in seconds. The gateway enforces this timeout for individual tool/method calls to MCP servers.", + "minimum": 1, + "default": 60 + }, + "payloadDir": { + "type": "string", + "description": "Directory path for storing large payload JSON files for authenticated clients. MUST be an absolute path: Unix paths start with '/', Windows paths start with a drive letter followed by ':\\'. Relative paths, empty strings, and paths that don't follow these conventions are not allowed.", + "minLength": 1, + "pattern": "^(/|[A-Za-z]:\\\\)" + }, + "payloadSizeThreshold": { + "type": "integer", + "description": "Size threshold in bytes for writing payloads to files instead of inlining them in the response. Payloads larger than this threshold are written to files in payloadDir. Defaults to 524288 (512KB) if not specified.", + "minimum": 1 + }, + "payloadPathPrefix": { + "type": "string", + "description": "Optional path prefix for payload file paths as seen from within agent containers. Use this when the payload directory is mounted at a different path inside the container than on the host.", + "minLength": 1 + }, + "trustedBots": { + "type": "array", + "description": "Additional trusted bot identity strings passed to the gateway and merged with its built-in internal trusted identity list. This field is additive and cannot remove entries from the gateway's built-in list. Typically GitHub bot usernames such as 'github-actions[bot]' or 'copilot-swe-agent[bot]'.", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + "opentelemetry": { + "type": "object", + "description": "OpenTelemetry OTLP tracing configuration (spec \u00a74.1.3.6). When present, endpoint is required and MUST be HTTPS. Enables distributed tracing of MCP tool invocations.", + "properties": { + "endpoint": { + "type": "string", + "description": "OTLP/HTTP collector URL. MUST use HTTPS (e.g., \"https://otel-collector.example.com\"). Supports ${VAR} expansion.", + "minLength": 1, + "pattern": "^(https://.+|\\$\\{[A-Za-z_][A-Za-z0-9_]*\\})$" + }, + "headers": { + "type": "string", + "description": "Comma-separated key=value HTTP headers sent with every OTLP export request (e.g. \"Authorization=Bearer ${OTEL_TOKEN}\"). Supports ${VAR} expansion.", + "pattern": "^(\\$\\{[A-Za-z_][A-Za-z0-9_]*\\}|\\s*[^=\\s,][^=,]*=.*?(\\s*,\\s*[^=\\s,][^=,]*=.*?)*)\\s*$" + }, + "traceId": { + "type": "string", + "description": "Parent trace ID in W3C format (32-char lowercase hex). Used to link gateway spans into a pre-existing trace. Supports ${VAR} expansion.", + "pattern": "^([0-9a-f]{32}|\\$\\{[A-Za-z_][A-Za-z0-9_]*\\})$" + }, + "spanId": { + "type": "string", + "description": "Parent span ID in W3C format (16-char lowercase hex). Paired with traceId to construct the traceparent header. Ignored when traceId is absent. Supports ${VAR} expansion.", + "pattern": "^([0-9a-f]{16}|\\$\\{[A-Za-z_][A-Za-z0-9_]*\\})$" }, - "gateway": { - "port": 8080, - "domain": "localhost", - "apiKey": "gateway-secret-token" + "serviceName": { + "type": "string", + "description": "service.name resource attribute for all emitted spans. Defaults to \"mcp-gateway\".", + "minLength": 1 } + }, + "required": [ + "endpoint" + ], + "additionalProperties": false + } + }, + "required": [ + "port", + "domain", + "apiKey" + ], + "additionalProperties": false + } + }, + "examples": [ + { + "mcpServers": { + "github": { + "container": "ghcr.io/github/github-mcp-server:latest", + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}" + } + } + }, + "gateway": { + "port": 8080, + "domain": "localhost", + "apiKey": "gateway-secret-token" + } + }, + { + "mcpServers": { + "data-server": { + "container": "ghcr.io/example/data-mcp:latest", + "entrypoint": "/custom/entrypoint.sh", + "entrypointArgs": [ + "--config", + "/app/config.json" + ], + "mounts": [ + "/host/data:/container/data:ro", + "/host/config:/container/config:rw" + ], + "type": "stdio" + } + }, + "gateway": { + "port": 8080, + "domain": "localhost", + "startupTimeout": 60, + "toolTimeout": 120 + } + }, + { + "mcpServers": { + "local-server": { + "container": "ghcr.io/example/python-mcp:latest", + "entrypointArgs": [ + "--config", + "/app/config.json" + ], + "type": "stdio" }, - { - "mcpServers": { - "mcp-scripts-server": { - "type": "safeinputs", - "tools": { - "greet-user": { - "description": "Greet a user by name", - "inputs": { - "name": { - "type": "string", - "required": true - } - }, - "script": "return { message: `Hello, ${name}!` };" - } - } + "remote-server": { + "type": "http", + "url": "https://api.example.com/mcp", + "headers": { + "Authorization": "Bearer ${API_TOKEN}" + } + } + }, + "gateway": { + "port": 8080, + "domain": "localhost", + "apiKey": "gateway-secret-token" + } + }, + { + "mcpServers": { + "mcp-scripts-server": { + "type": "safeinputs", + "tools": { + "greet-user": { + "description": "Greet a user by name", + "inputs": { + "name": { + "type": "string", + "required": true } - }, - "gateway": { - "port": 8080, - "domain": "localhost", - "apiKey": "gateway-secret-token" - }, - "customSchemas": { - "safeinputs": "https://docs.github.com/gh-aw/schemas/mcp-scripts-config.schema.json" + }, + "script": "return { message: `Hello, ${name}!` };" } + } } - ] + }, + "gateway": { + "port": 8080, + "domain": "localhost", + "apiKey": "gateway-secret-token" + }, + "customSchemas": { + "safeinputs": "https://docs.github.com/gh-aw/schemas/mcp-scripts-config.schema.json" + } + } + ] } diff --git a/internal/config/validation.go b/internal/config/validation.go index 7c8486f5..a29bc2fb 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -149,12 +149,12 @@ func expandTracingVariables(cfg *TracingConfig) error { cfg.SpanID = expanded } - for key, value := range cfg.Headers { - expanded, err := expandVariables(value, fmt.Sprintf("gateway.opentelemetry.headers.%s", key)) + if cfg.Headers != "" { + expanded, err := expandVariables(cfg.Headers, "gateway.opentelemetry.headers") if err != nil { return err } - cfg.Headers[key] = expanded + cfg.Headers = expanded } return nil diff --git a/internal/tracing/parse_headers_test.go b/internal/tracing/parse_headers_test.go new file mode 100644 index 00000000..644b1a3a --- /dev/null +++ b/internal/tracing/parse_headers_test.go @@ -0,0 +1,89 @@ +package tracing + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestParseOTLPHeaders covers the parseOTLPHeaders helper with a range of inputs. +func TestParseOTLPHeaders(t *testing.T) { + tests := []struct { + name string + input string + expected map[string]string + }{ + { + name: "empty string", + input: "", + expected: map[string]string{}, + }, + { + name: "single well-formed pair", + input: "Authorization=Bearer test-token", + expected: map[string]string{ + "Authorization": "Bearer test-token", + }, + }, + { + name: "multiple well-formed pairs", + input: "Authorization=Bearer test-token,X-Request-ID=req-123", + expected: map[string]string{ + "Authorization": "Bearer test-token", + "X-Request-ID": "req-123", + }, + }, + { + name: "whitespace is trimmed around keys and values", + input: " Authorization = Bearer test-token , X-Request-ID = req-123 ", + expected: map[string]string{ + "Authorization": "Bearer test-token", + "X-Request-ID": "req-123", + }, + }, + { + name: "value containing '=' is preserved", + input: "Authorization=Basic dXNlcjpwYXNz==", + expected: map[string]string{ + "Authorization": "Basic dXNlcjpwYXNz==", + }, + }, + { + name: "malformed pair without '=' is skipped", + input: "Authorization=Bearer test-token,malformed,X-Trace-ID=trace-123", + expected: map[string]string{ + "Authorization": "Bearer test-token", + "X-Trace-ID": "trace-123", + }, + }, + { + name: "pair with empty key is skipped", + input: "Authorization=Bearer test-token,=empty-key,X-Trace-ID=trace-123", + expected: map[string]string{ + "Authorization": "Bearer test-token", + "X-Trace-ID": "trace-123", + }, + }, + { + name: "pair with whitespace-only key is skipped", + input: "Authorization=Bearer test-token, =whitespace-key", + expected: map[string]string{ + "Authorization": "Bearer test-token", + }, + }, + { + name: "empty trailing comma is skipped", + input: "Authorization=Bearer test-token,", + expected: map[string]string{ + "Authorization": "Bearer test-token", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseOTLPHeaders(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/tracing/provider.go b/internal/tracing/provider.go index 14ea2aa9..c7094ead 100644 --- a/internal/tracing/provider.go +++ b/internal/tracing/provider.go @@ -22,6 +22,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "strings" "time" "go.opentelemetry.io/otel" @@ -93,12 +94,38 @@ func resolveSampleRate(cfg *config.TracingConfig) float64 { return config.DefaultTracingSampleRate } -// resolveHeaders returns the configured OTLP export headers (or nil). +// parseOTLPHeaders parses a comma-separated "key=value" string into a map. +// Empty pairs, pairs without "=", and pairs with an empty key are logged as +// warnings and skipped to avoid invalid HTTP header field names. +// Leading/trailing whitespace around keys and values is trimmed. +func parseOTLPHeaders(raw string) map[string]string { + headers := make(map[string]string) + for _, pair := range strings.Split(raw, ",") { + trimmed := strings.TrimSpace(pair) + if trimmed == "" { + continue + } + k, v, ok := strings.Cut(trimmed, "=") + if !ok { + logTracing.Printf("Warning: skipping malformed OTLP header pair (missing '=')") + continue + } + key := strings.TrimSpace(k) + if key == "" { + logTracing.Printf("Warning: skipping OTLP header pair with empty key") + continue + } + headers[key] = strings.TrimSpace(v) + } + return headers +} + +// resolveHeaders parses the configured OTLP export headers string (or returns nil). func resolveHeaders(cfg *config.TracingConfig) map[string]string { - if cfg != nil && len(cfg.Headers) > 0 { - return cfg.Headers + if cfg == nil || cfg.Headers == "" { + return nil } - return nil + return parseOTLPHeaders(cfg.Headers) } // resolveParentContext builds a context carrying the W3C remote parent span context diff --git a/internal/tracing/provider_test.go b/internal/tracing/provider_test.go index f29f6f04..d3849d52 100644 --- a/internal/tracing/provider_test.go +++ b/internal/tracing/provider_test.go @@ -314,48 +314,92 @@ func TestWrapHTTPHandler_GeneratesRootSpan(t *testing.T) { } // TestInitProvider_WithHeaders verifies that OTLP export headers are forwarded -// to the collector. A channel synchronises with the test HTTP server so the -// assertion is deterministic rather than timing-dependent. +// to the collector. Table-driven sub-tests cover single headers, multiple +// headers with whitespace trimming, and malformed/empty-key cases that must be +// skipped. A channel synchronises with the test HTTP server so assertions are +// deterministic rather than timing-dependent. func TestInitProvider_WithHeaders(t *testing.T) { ctx := context.Background() - // Channel signals when the test server receives an export request. - received := make(chan string, 1) - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - select { - case received <- r.Header.Get("Authorization"): - default: - } - w.WriteHeader(http.StatusOK) - })) - defer ts.Close() - - cfg := &config.TracingConfig{ - Endpoint: ts.URL, - Headers: map[string]string{"Authorization": "Bearer test-token"}, + tests := []struct { + name string + headers string + expectedValues map[string]string // canonical HTTP header name → expected value + notExpectedSet []string // canonical HTTP header names that must NOT be present + }{ + { + name: "single well-formed header", + headers: "Authorization=Bearer test-token", + expectedValues: map[string]string{ + "Authorization": "Bearer test-token", + }, + }, + { + name: "multiple headers with whitespace trimmed", + headers: " Authorization = Bearer test-token , X-Request-Id = req-123 ", + expectedValues: map[string]string{ + "Authorization": "Bearer test-token", + "X-Request-Id": "req-123", + }, + }, + { + name: "malformed and empty-key headers are skipped", + headers: "Authorization=Bearer test-token, malformed, =empty-key, X-Trace-Id=trace-123", + expectedValues: map[string]string{ + "Authorization": "Bearer test-token", + "X-Trace-Id": "trace-123", + }, + notExpectedSet: []string{"Malformed"}, + }, } - provider, err := tracing.InitProvider(ctx, cfg) - require.NoError(t, err) - require.NotNil(t, provider) - - // Create and end a span to trigger an export attempt. - tr := provider.Tracer() - _, span := tr.Start(ctx, "header-test-span") - span.End() - - // Shutdown flushes the batch processor, ensuring the export is sent. - shutdownCtx, cancel := context.WithTimeout(ctx, 2*time.Second) - defer cancel() - _ = provider.Shutdown(shutdownCtx) - - // Wait for the export request with a timeout. - select { - case auth := <-received: - assert.Equal(t, "Bearer test-token", auth, - "Authorization header must be forwarded to the OTLP collector") - case <-time.After(3 * time.Second): - t.Fatal("timed out waiting for OTLP export request — headers test is non-deterministic") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Channel signals when the test server receives an export request. + received := make(chan http.Header, 1) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case received <- r.Header.Clone(): + default: + } + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + cfg := &config.TracingConfig{ + Endpoint: ts.URL, + Headers: tt.headers, + } + + provider, err := tracing.InitProvider(ctx, cfg) + require.NoError(t, err) + require.NotNil(t, provider) + + // Create and end a span to trigger an export attempt. + tr := provider.Tracer() + _, span := tr.Start(ctx, "header-test-span") + span.End() + + // Shutdown flushes the batch processor, ensuring the export is sent. + shutdownCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + _ = provider.Shutdown(shutdownCtx) + + // Wait for the export request with a timeout. + select { + case headers := <-received: + for key, expectedValue := range tt.expectedValues { + assert.Equal(t, expectedValue, headers.Get(key), + fmt.Sprintf("%s header must be forwarded to the OTLP collector", key)) + } + for _, key := range tt.notExpectedSet { + assert.Empty(t, headers.Get(key), + fmt.Sprintf("%s header must not be sent when pair is malformed", key)) + } + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for OTLP export request — headers test is non-deterministic") + } + }) } }