diff --git a/internal/config/fetch_and_fix_schema_test.go b/internal/config/fetch_and_fix_schema_test.go index 4c0fe7a8..29544982 100644 --- a/internal/config/fetch_and_fix_schema_test.go +++ b/internal/config/fetch_and_fix_schema_test.go @@ -2,40 +2,30 @@ package config import ( "encoding/json" - "net/http" - "net/http/httptest" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// Tests for fetchAndFixSchema schema transformation logic. +// Tests for fixSchemaBytes schema transformation logic. // -// fetchAndFixSchema applies three transformations to a raw schema JSON before +// fixSchemaBytes applies three transformations to a raw schema JSON before // compilation to work around JSON Schema Draft 7 limitations: // // 1. customServerConfig.type: replaces negative-lookahead pattern with not/enum // 2. customSchemas.patternProperties: removes negative-lookahead key, adds simple key // 3. stdioServerConfig / httpServerConfig: injects registry and guard-policies fields -// -// Existing tests (validate_against_custom_schema_test.go) exercise the HTTP-level -// paths (non-200, unreachable) using simple mock schemas that lack the above -// structures. These tests exercise the three transformation branches specifically. -// schemaServer is a test helper that returns an HTTP test server serving the given schema. -// It avoids calling require/t.FailNow inside the handler goroutine (unsafe in Go <1.21). -func schemaServer(t *testing.T, schema map[string]interface{}) *httptest.Server { +// marshalSchema is a test helper that marshals a schema map to JSON bytes. +func marshalSchema(t *testing.T, schema map[string]interface{}) []byte { t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(schema); err != nil { - t.Errorf("schemaServer: failed to encode schema: %v", err) - } - })) + b, err := json.Marshal(schema) + require.NoError(t, err) + return b } -// unmarshalSchema is a test helper that unmarshals fetchAndFixSchema output. +// unmarshalSchema is a test helper that unmarshals fixSchemaBytes output. func unmarshalSchema(t *testing.T, schemaBytes []byte) map[string]interface{} { t.Helper() var result map[string]interface{} @@ -43,25 +33,18 @@ func unmarshalSchema(t *testing.T, schemaBytes []byte) map[string]interface{} { return result } -// TestFetchAndFixSchema_InvalidJSONResponse covers the json.Unmarshal failure path -// (validation_schema.go lines 113-115) when the server returns malformed JSON. -func TestFetchAndFixSchema_InvalidJSONResponse(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte("this is not valid JSON {{{")) - })) - defer srv.Close() - - _, err := fetchAndFixSchema(srv.URL) +// TestFixSchemaBytes_InvalidJSON covers the json.Unmarshal failure path +// when the input is malformed JSON. +func TestFixSchemaBytes_InvalidJSON(t *testing.T) { + _, err := fixSchemaBytes([]byte("this is not valid JSON {{{")) require.Error(t, err) assert.Contains(t, err.Error(), "failed to parse schema") } -// TestFetchAndFixSchema_TransformCustomServerConfigType covers lines 121-138: -// the customServerConfig.type transformation that removes the negative-lookahead -// pattern and adds a not/enum constraint instead. -func TestFetchAndFixSchema_TransformCustomServerConfigType(t *testing.T) { +// TestFixSchemaBytes_TransformCustomServerConfigType covers the customServerConfig.type +// transformation that removes the negative-lookahead pattern and adds a not/enum constraint. +func TestFixSchemaBytes_TransformCustomServerConfigType(t *testing.T) { schema := map[string]interface{}{ "$schema": "http://json-schema.org/draft-07/schema#", "definitions": map[string]interface{}{ @@ -76,10 +59,8 @@ func TestFetchAndFixSchema_TransformCustomServerConfigType(t *testing.T) { }, }, } - srv := schemaServer(t, schema) - defer srv.Close() - result, err := fetchAndFixSchema(srv.URL) + result, err := fixSchemaBytes(marshalSchema(t, schema)) require.NoError(t, err) got := unmarshalSchema(t, result) @@ -104,9 +85,9 @@ func TestFetchAndFixSchema_TransformCustomServerConfigType(t *testing.T) { assert.Contains(t, enumSlice, "http", "not.enum should exclude 'http'") } -// TestFetchAndFixSchema_CustomServerConfigType_MissingSubStructures verifies that the +// TestFixSchemaBytes_CustomServerConfigType_MissingSubStructures verifies that the // transformation is a no-op when intermediate keys (properties, type) are absent. -func TestFetchAndFixSchema_CustomServerConfigType_MissingSubStructures(t *testing.T) { +func TestFixSchemaBytes_CustomServerConfigType_MissingSubStructures(t *testing.T) { tests := []struct { name string schema map[string]interface{} @@ -154,10 +135,7 @@ func TestFetchAndFixSchema_CustomServerConfigType_MissingSubStructures(t *testin for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - srv := schemaServer(t, tt.schema) - defer srv.Close() - - result, err := fetchAndFixSchema(srv.URL) + result, err := fixSchemaBytes(marshalSchema(t, tt.schema)) require.NoError(t, err, "missing structures should not cause errors") assert.NotEmpty(t, result, "should return non-empty bytes") @@ -165,10 +143,10 @@ func TestFetchAndFixSchema_CustomServerConfigType_MissingSubStructures(t *testin } } -// TestFetchAndFixSchema_TransformCustomSchemasPatternProperties covers lines 140-156: -// the patternProperties transformation that removes the negative-lookahead key and -// replaces it with the simple "^[a-z][a-z0-9-]*$" key. -func TestFetchAndFixSchema_TransformCustomSchemasPatternProperties(t *testing.T) { +// TestFixSchemaBytes_TransformCustomSchemasPatternProperties covers the patternProperties +// transformation that removes the negative-lookahead key and replaces it with the simple +// "^[a-z][a-z0-9-]*$" key. +func TestFixSchemaBytes_TransformCustomSchemasPatternProperties(t *testing.T) { customTypeDef := map[string]interface{}{ "type": "string", "description": "URL to the custom type schema", @@ -184,10 +162,8 @@ func TestFetchAndFixSchema_TransformCustomSchemasPatternProperties(t *testing.T) }, }, } - srv := schemaServer(t, schema) - defer srv.Close() - result, err := fetchAndFixSchema(srv.URL) + result, err := fixSchemaBytes(marshalSchema(t, schema)) require.NoError(t, err) got := unmarshalSchema(t, result) @@ -207,10 +183,10 @@ func TestFetchAndFixSchema_TransformCustomSchemasPatternProperties(t *testing.T) assert.Equal(t, "string", simpleMap["type"], "replacement value should preserve original definition") } -// TestFetchAndFixSchema_CustomSchemasPatternProperties_NoNegativeLookahead verifies +// TestFixSchemaBytes_CustomSchemasPatternProperties_NoNegativeLookahead verifies // that the patternProperties transformation is skipped when no negative-lookahead key // is present. -func TestFetchAndFixSchema_CustomSchemasPatternProperties_NoNegativeLookahead(t *testing.T) { +func TestFixSchemaBytes_CustomSchemasPatternProperties_NoNegativeLookahead(t *testing.T) { schema := map[string]interface{}{ "properties": map[string]interface{}{ "customSchemas": map[string]interface{}{ @@ -220,10 +196,8 @@ func TestFetchAndFixSchema_CustomSchemasPatternProperties_NoNegativeLookahead(t }, }, } - srv := schemaServer(t, schema) - defer srv.Close() - result, err := fetchAndFixSchema(srv.URL) + result, err := fixSchemaBytes(marshalSchema(t, schema)) require.NoError(t, err) got := unmarshalSchema(t, result) @@ -236,9 +210,9 @@ func TestFetchAndFixSchema_CustomSchemasPatternProperties_NoNegativeLookahead(t assert.True(t, hasSimple, "existing simple pattern key should be preserved when no negative-lookahead present") } -// TestFetchAndFixSchema_AddRegistryAndGuardPoliciesToStdioConfig covers lines 179-184: -// the injection of registry and guard-policies into stdioServerConfig.properties. -func TestFetchAndFixSchema_AddRegistryAndGuardPoliciesToStdioConfig(t *testing.T) { +// TestFixSchemaBytes_AddRegistryAndGuardPoliciesToStdioConfig covers the injection +// of registry and guard-policies into stdioServerConfig.properties. +func TestFixSchemaBytes_AddRegistryAndGuardPoliciesToStdioConfig(t *testing.T) { schema := map[string]interface{}{ "$schema": "http://json-schema.org/draft-07/schema#", "definitions": map[string]interface{}{ @@ -250,10 +224,8 @@ func TestFetchAndFixSchema_AddRegistryAndGuardPoliciesToStdioConfig(t *testing.T }, }, } - srv := schemaServer(t, schema) - defer srv.Close() - result, err := fetchAndFixSchema(srv.URL) + result, err := fixSchemaBytes(marshalSchema(t, schema)) require.NoError(t, err) got := unmarshalSchema(t, result) @@ -276,9 +248,9 @@ func TestFetchAndFixSchema_AddRegistryAndGuardPoliciesToStdioConfig(t *testing.T assert.Equal(t, true, gpMap["additionalProperties"], "guard-policies.additionalProperties should be true") } -// TestFetchAndFixSchema_AddRegistryAndGuardPoliciesToHttpConfig covers lines 186-191: -// the injection of registry and guard-policies into httpServerConfig.properties. -func TestFetchAndFixSchema_AddRegistryAndGuardPoliciesToHttpConfig(t *testing.T) { +// TestFixSchemaBytes_AddRegistryAndGuardPoliciesToHttpConfig covers the injection +// of registry and guard-policies into httpServerConfig.properties. +func TestFixSchemaBytes_AddRegistryAndGuardPoliciesToHttpConfig(t *testing.T) { schema := map[string]interface{}{ "$schema": "http://json-schema.org/draft-07/schema#", "definitions": map[string]interface{}{ @@ -290,10 +262,8 @@ func TestFetchAndFixSchema_AddRegistryAndGuardPoliciesToHttpConfig(t *testing.T) }, }, } - srv := schemaServer(t, schema) - defer srv.Close() - result, err := fetchAndFixSchema(srv.URL) + result, err := fixSchemaBytes(marshalSchema(t, schema)) require.NoError(t, err) got := unmarshalSchema(t, result) @@ -313,9 +283,9 @@ func TestFetchAndFixSchema_AddRegistryAndGuardPoliciesToHttpConfig(t *testing.T) assert.True(t, hasURL, "original url field should be preserved in httpServerConfig.properties") } -// TestFetchAndFixSchema_RegistryGuardPolicies_MissingSubStructures verifies that the +// TestFixSchemaBytes_RegistryGuardPolicies_MissingSubStructures verifies that the // registry/guard-policies injection is skipped gracefully when sub-structures are absent. -func TestFetchAndFixSchema_RegistryGuardPolicies_MissingSubStructures(t *testing.T) { +func TestFixSchemaBytes_RegistryGuardPolicies_MissingSubStructures(t *testing.T) { tests := []struct { name string schema map[string]interface{} @@ -348,10 +318,7 @@ func TestFetchAndFixSchema_RegistryGuardPolicies_MissingSubStructures(t *testing for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - srv := schemaServer(t, tt.schema) - defer srv.Close() - - result, err := fetchAndFixSchema(srv.URL) + result, err := fixSchemaBytes(marshalSchema(t, tt.schema)) require.NoError(t, err, "missing sub-structures should not cause errors") assert.NotEmpty(t, result) @@ -359,10 +326,10 @@ func TestFetchAndFixSchema_RegistryGuardPolicies_MissingSubStructures(t *testing } } -// TestFetchAndFixSchema_AllTransformationsApplied verifies that all three +// TestFixSchemaBytes_AllTransformationsApplied verifies that all three // transformation branches run correctly when a single schema contains all the // structures that trigger them. -func TestFetchAndFixSchema_AllTransformationsApplied(t *testing.T) { +func TestFixSchemaBytes_AllTransformationsApplied(t *testing.T) { schema := map[string]interface{}{ "$schema": "http://json-schema.org/draft-07/schema#", // Trigger #1: customServerConfig.type with negative-lookahead pattern @@ -395,10 +362,8 @@ func TestFetchAndFixSchema_AllTransformationsApplied(t *testing.T) { }, }, } - srv := schemaServer(t, schema) - defer srv.Close() - result, err := fetchAndFixSchema(srv.URL) + result, err := fixSchemaBytes(marshalSchema(t, schema)) require.NoError(t, err) got := unmarshalSchema(t, result) @@ -440,10 +405,10 @@ func TestFetchAndFixSchema_AllTransformationsApplied(t *testing.T) { assert.True(t, hasHTTPGP, "guard-policies should be injected into httpServerConfig") } -// TestFetchAndFixSchema_PreservesSchemaIntegrity verifies that fetchAndFixSchema -// preserves existing stdioServerConfig fields and structure while applying its -// transformations (i.e., original properties and required entries remain intact). -func TestFetchAndFixSchema_PreservesSchemaIntegrity(t *testing.T) { +// TestFixSchemaBytes_PreservesSchemaIntegrity verifies that fixSchemaBytes preserves +// existing stdioServerConfig fields and structure while applying its transformations +// (i.e., original properties and required entries remain intact). +func TestFixSchemaBytes_PreservesSchemaIntegrity(t *testing.T) { schema := map[string]interface{}{ "$schema": "http://json-schema.org/draft-07/schema#", "definitions": map[string]interface{}{ @@ -459,10 +424,8 @@ func TestFetchAndFixSchema_PreservesSchemaIntegrity(t *testing.T) { }, }, } - srv := schemaServer(t, schema) - defer srv.Close() - result, err := fetchAndFixSchema(srv.URL) + result, err := fixSchemaBytes(marshalSchema(t, schema)) require.NoError(t, err) got := unmarshalSchema(t, result) diff --git a/internal/config/schema/mcp-gateway-config.schema.json b/internal/config/schema/mcp-gateway-config.schema.json new file mode 100644 index 00000000..0c7119e6 --- /dev/null +++ b/internal/config/schema/mcp-gateway-config.schema.json @@ -0,0 +1,346 @@ +{ + "$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 as defined in the MCP Gateway Specification v1.0.0. 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" + }, + { + "$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" + }, + "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 + }, + "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 + }, + { + "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 + } + }, + "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" + }, + "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 + } + }, + "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_schema.go b/internal/config/validation_schema.go index a446d160..5428a87b 100644 --- a/internal/config/validation_schema.go +++ b/internal/config/validation_schema.go @@ -1,6 +1,7 @@ package config import ( + _ "embed" "encoding/json" "fmt" "io" @@ -16,9 +17,27 @@ import ( "github.com/santhosh-tekuri/jsonschema/v5" ) +// embeddedSchemaBytes holds the bundled MCP Gateway configuration JSON Schema (v0.64.4). +// Embedding the schema in the binary eliminates the runtime network request that was +// previously needed to fetch it from GitHub, improving startup reliability and removing +// a potential point of failure in network-restricted environments. +// +// To update the schema to a newer version: +// 1. Download the new schema from https://github.com/github/gh-aw/releases +// 2. Replace internal/config/schema/mcp-gateway-config.schema.json +// 3. Update the version comment above +// 4. Run tests to ensure compatibility: make test +// +//go:embed schema/mcp-gateway-config.schema.json +var embeddedSchemaBytes []byte + const ( // maxSchemaFetchRetries is the number of fetch attempts before giving up. maxSchemaFetchRetries = 3 + + // embeddedSchemaID is the $id URL used when registering the embedded schema with + // the JSON Schema compiler. It matches the $id field in the bundled schema file. + embeddedSchemaID = "https://docs.github.com/gh-aw/schemas/mcp-gateway-config.schema.json" ) // schemaFetchRetryDelay is the base delay between retry attempts using exponential @@ -48,23 +67,6 @@ var ( // logSchema is the debug logger for schema validation logSchema = logger.New("config:validation_schema") - // Schema URL configuration - // This URL points to the source of truth for the MCP Gateway configuration schema. - // - // Schema Version Pinning: - // The schema is fetched from the main branch to get the latest version. - // - // To update to a specific pinned version: - // 1. Check the latest gh-aw release: https://github.com/github/gh-aw/releases - // 2. Update the URL below to use a version tag instead of main - // 3. Run tests to ensure compatibility: make test - // 4. Update this comment with the version number - // - // Current schema version: v0.50.7 - // - // Alternative: Embed the schema using go:embed directive for zero network dependency. - schemaURL = "https://raw.githubusercontent.com/github/gh-aw/v0.65.0/docs/public/schemas/mcp-gateway-config.schema.json" - // Schema caching to avoid recompiling the JSON schema on every validation // This improves performance by compiling the schema once and reusing it schemaOnce sync.Once @@ -72,8 +74,7 @@ var ( schemaErr error ) -// fetchAndFixSchema fetches the JSON schema from the remote URL and applies -// workarounds for JSON Schema Draft 7 limitations. +// fixSchemaBytes applies workarounds to a raw schema JSON for JSON Schema Draft 7 limitations. // // Background: // The MCP Gateway configuration schema uses regex patterns with negative lookahead @@ -100,70 +101,7 @@ var ( // TODO: Investigate if JSON Schema v6 (library upgrade) or Draft 2019-09+/2020-12 // (newer spec) eliminate this workaround. The jsonschema/v6 Go library may handle // these patterns natively, potentially allowing removal of this function entirely. -func fetchAndFixSchema(url string) ([]byte, error) { - startTime := time.Now() - logSchema.Printf("Fetching schema from URL: %s", url) - - client := &http.Client{ - Timeout: schemaHTTPClientTimeout, - } - - var resp *http.Response - var lastErr error - - for attempt := 1; attempt <= maxSchemaFetchRetries; attempt++ { - if attempt > 1 { - delay := schemaFetchRetryDelay << uint(attempt-2) // 1×, 2×, 4× base delay - logSchema.Printf("Retrying schema fetch (attempt %d/%d) after %v: %v", attempt, maxSchemaFetchRetries, delay, lastErr) - time.Sleep(delay) - } - - fetchStart := time.Now() - var err error - resp, err = client.Get(url) - if err != nil { - logSchema.Printf("Schema fetch attempt %d failed after %v: %v", attempt, time.Since(fetchStart), err) - lastErr = fmt.Errorf("failed to fetch schema from %s: %w", url, err) - resp = nil - continue - } - logSchema.Printf("HTTP request attempt %d completed in %v with status %d", attempt, time.Since(fetchStart), resp.StatusCode) - - if resp.StatusCode == http.StatusOK { - lastErr = nil - break - } - - if isTransientHTTPError(resp.StatusCode) { - lastErr = fmt.Errorf("failed to fetch schema: HTTP %d", resp.StatusCode) - logSchema.Printf("Schema fetch attempt %d returned transient error: HTTP %d, will retry", attempt, resp.StatusCode) - resp.Body.Close() - resp = nil - continue - } - - // Permanent HTTP error (404, 403, 401, etc.) — do not retry. - lastErr = fmt.Errorf("failed to fetch schema: HTTP %d", resp.StatusCode) - logSchema.Printf("Schema fetch returned permanent error: HTTP %d", resp.StatusCode) - resp.Body.Close() - resp = nil - break - } - - if resp == nil { - return nil, lastErr - } - defer resp.Body.Close() - logSchema.Printf("HTTP request completed in %v", time.Since(startTime)) - - readStart := time.Now() - schemaBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read schema response: %w", err) - } - logSchema.Printf("Schema read completed in %v (size: %d bytes)", time.Since(readStart), len(schemaBytes)) - - // Fix regex patterns that use negative lookahead +func fixSchemaBytes(schemaBytes []byte) ([]byte, error) { fixStart := time.Now() var schema map[string]interface{} if err := json.Unmarshal(schemaBytes, &schema); err != nil { @@ -272,29 +210,97 @@ func fetchAndFixSchema(url string) ([]byte, error) { } logSchema.Printf("Schema fixes applied in %v", time.Since(fixStart)) - logSchema.Printf("Total schema fetch and fix completed in %v", time.Since(startTime)) return fixedBytes, nil } +// fetchAndFixSchema fetches the JSON schema from the remote URL and applies +// workarounds for JSON Schema Draft 7 limitations via fixSchemaBytes. +// This function is used for fetching custom server schemas from remote URLs. +func fetchAndFixSchema(url string) ([]byte, error) { + startTime := time.Now() + logSchema.Printf("Fetching schema from URL: %s", url) + + client := &http.Client{ + Timeout: schemaHTTPClientTimeout, + } + + var resp *http.Response + var lastErr error + + for attempt := 1; attempt <= maxSchemaFetchRetries; attempt++ { + if attempt > 1 { + delay := schemaFetchRetryDelay << uint(attempt-2) // 1×, 2×, 4× base delay + logSchema.Printf("Retrying schema fetch (attempt %d/%d) after %v: %v", attempt, maxSchemaFetchRetries, delay, lastErr) + time.Sleep(delay) + } + + fetchStart := time.Now() + var err error + resp, err = client.Get(url) + if err != nil { + logSchema.Printf("Schema fetch attempt %d failed after %v: %v", attempt, time.Since(fetchStart), err) + lastErr = fmt.Errorf("failed to fetch schema from %s: %w", url, err) + resp = nil + continue + } + logSchema.Printf("HTTP request attempt %d completed in %v with status %d", attempt, time.Since(fetchStart), resp.StatusCode) + + if resp.StatusCode == http.StatusOK { + lastErr = nil + break + } + + if isTransientHTTPError(resp.StatusCode) { + lastErr = fmt.Errorf("failed to fetch schema: HTTP %d", resp.StatusCode) + logSchema.Printf("Schema fetch attempt %d returned transient error: HTTP %d, will retry", attempt, resp.StatusCode) + resp.Body.Close() + resp = nil + continue + } + + // Permanent HTTP error (404, 403, 401, etc.) — do not retry. + lastErr = fmt.Errorf("failed to fetch schema: HTTP %d", resp.StatusCode) + logSchema.Printf("Schema fetch returned permanent error: HTTP %d", resp.StatusCode) + resp.Body.Close() + resp = nil + break + } + + if resp == nil { + return nil, lastErr + } + defer resp.Body.Close() + logSchema.Printf("HTTP request completed in %v", time.Since(startTime)) + + readStart := time.Now() + schemaBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read schema response: %w", err) + } + logSchema.Printf("Schema read completed in %v (size: %d bytes)", time.Since(readStart), len(schemaBytes)) + + return fixSchemaBytes(schemaBytes) +} + // getOrCompileSchema retrieves the cached compiled schema or compiles it on first use. // This function uses sync.Once to ensure thread-safe, one-time schema compilation, -// which significantly improves performance by avoiding repeated schema fetching and -// compilation on every validation call. +// which significantly improves performance by avoiding repeated schema compilation +// on every validation call. // -// The schema is fetched from the remote URL on first call and cached for subsequent uses. -// If schema compilation fails, the error is also cached to avoid repeated fetch attempts. +// The schema is bundled in the binary via go:embed and fixed on first call. +// If schema compilation fails, the error is also cached to avoid repeated attempts. // // Returns: // - Compiled JSON schema on success -// - Error if schema fetch or compilation fails +// - Error if schema compilation fails func getOrCompileSchema() (*jsonschema.Schema, error) { schemaOnce.Do(func() { logSchema.Print("Compiling JSON schema for the first time") - // Fetch the schema from the configured URL - schemaJSON, fetchErr := fetchAndFixSchema(schemaURL) - if fetchErr != nil { - schemaErr = fmt.Errorf("failed to fetch schema: %w", fetchErr) + // Apply fixes to the embedded schema bytes + schemaJSON, fixErr := fixSchemaBytes(embeddedSchemaBytes) + if fixErr != nil { + schemaErr = fmt.Errorf("failed to process embedded schema: %w", fixErr) logSchema.Printf("Schema compilation failed: %v", schemaErr) return } @@ -308,25 +314,18 @@ func getOrCompileSchema() (*jsonschema.Schema, error) { schemaID, ok := schemaObj["$id"].(string) if !ok || schemaID == "" { - schemaID = schemaURL + schemaID = embeddedSchemaID } // Compile the schema compiler := jsonschema.NewCompiler() compiler.Draft = jsonschema.Draft7 - // Add the schema with both URLs (the fetch URL and the $id URL) - // This ensures references work correctly regardless of which URL is used - if addErr := compiler.AddResource(schemaURL, strings.NewReader(string(schemaJSON))); addErr != nil { + // Add the schema using its $id URL so internal $ref references resolve correctly + if addErr := compiler.AddResource(schemaID, strings.NewReader(string(schemaJSON))); addErr != nil { schemaErr = fmt.Errorf("failed to add schema resource: %w", addErr) return } - if schemaID != schemaURL { - if addErr := compiler.AddResource(schemaID, strings.NewReader(string(schemaJSON))); addErr != nil { - schemaErr = fmt.Errorf("failed to add schema resource with $id: %w", addErr) - return - } - } cachedSchema, schemaErr = compiler.Compile(schemaID) if schemaErr != nil { diff --git a/internal/config/validation_schema_test.go b/internal/config/validation_schema_test.go index caa856eb..45618129 100644 --- a/internal/config/validation_schema_test.go +++ b/internal/config/validation_schema_test.go @@ -657,17 +657,12 @@ func TestSchemaCaching(t *testing.T) { assert.NoError(t, err, "Validation with cached schema should succeed") } -// TestSchemaURLConfiguration verifies that the schema URL is configurable -func TestSchemaURLConfiguration(t *testing.T) { - // Verify the schema URL is properly set - // This test documents the schema URL configuration for version pinning +// TestSchemaConfiguration verifies that the embedded schema is bundled correctly +func TestSchemaConfiguration(t *testing.T) { + // Verify the embedded schema bytes are non-empty + assert.NotEmpty(t, embeddedSchemaBytes, "Embedded schema should not be empty") - // The current implementation uses 'main' branch - // For production, consider pinning to a specific commit SHA or version tag - expectedPattern := "https://raw.githubusercontent.com/github/gh-aw/" - - // We can't directly test the package-level schemaURL variable, - // but we can verify that the schema compiles and validates correctly + // Verify that the schema compiles and validates correctly schema, err := getOrCompileSchema() assert.NoError(t, err, "Schema compilation should succeed") assert.NotNil(t, schema, "Schema should not be nil") @@ -687,10 +682,7 @@ func TestSchemaURLConfiguration(t *testing.T) { }` err = validateJSONSchema([]byte(validConfig)) - assert.NoError(t, err, "Validation should succeed with configured schema URL") + assert.NoError(t, err, "Validation should succeed with embedded schema") - // Document the version pinning approach in test output - t.Logf("Schema URL pattern: %s", expectedPattern) - t.Logf("For production builds, consider pinning to: %s/...", expectedPattern) - t.Logf("Or use a version tag: %sv1.0.0/...", expectedPattern) + t.Logf("Embedded schema size: %d bytes", len(embeddedSchemaBytes)) }