diff --git a/CLAUDE.md b/CLAUDE.md index 493b5ff..9acd110 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -550,6 +550,48 @@ cors: - **Development** (`debug=true`): Permissive origin validation for easy testing - **Production** (`debug=false`): Strict origin validation with localhost fallback for debugging +### Browser Client Configuration + +For browser-based MCP clients, configure CORS to expose necessary headers: + +**Basic Browser Support:** +```yaml +cors: + allow_origins: ["https://your-web-app.com"] + allow_credentials: true + expose_headers: [] # Empty by default +``` + +**Advanced Browser Configuration:** +```yaml +cors: + allow_origins: ["https://your-web-app.com"] + allow_credentials: true + allow_methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"] + allow_headers: ["Authorization", "Content-Type", "MCP-Protocol-Version"] + expose_headers: # Headers browser clients can access + - "MCP-Session-ID" # For session persistence + - "X-Request-ID" # For request tracking + - "X-Rate-Limit-Remaining" # For usage display + - "X-Custom-Header" # Your custom headers +``` + +**What `expose_headers` Does:** +- By default, browsers can only access basic headers (Content-Type, etc.) +- `expose_headers` allows JavaScript to read custom response headers +- Essential for MCP clients that need session IDs or request tracking + +**Browser Client Example:** +```javascript +// Without expose_headers, these return null: +const sessionId = response.headers.get('MCP-Session-ID'); // null +const requestId = response.headers.get('X-Request-ID'); // null + +// With expose_headers configured, these work: +const sessionId = response.headers.get('MCP-Session-ID'); // "session-123" +const requestId = response.headers.get('X-Request-ID'); // "req-456" +``` + ### Adding New MCP Services 1. **Configure the service** in `config.yaml`: diff --git a/config.example.yaml b/config.example.yaml index 7640f0b..bc465f3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -18,6 +18,7 @@ cors: - "DELETE" - "OPTIONS" allow_headers: ["*"] # Allowed headers (use specific headers in production) + expose_headers: [] # Headers exposed to browser clients (e.g., ["MCP-Session-ID", "X-Request-ID"]) # Storage backend configuration storage: diff --git a/docs/architecture.md b/docs/architecture.md index 68fd241..74996eb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -234,6 +234,48 @@ The gateway uses a component-based architecture. Key components: - **PKCE**: Required for all authorization flows - **Audience Binding**: Service-specific token scoping +### 7. Browser Client Support + +**Decision**: CORS configuration with exposed headers for browser-based MCP clients. + +**Why Browser Support Matters**: +- Web-based MCP clients need access to custom headers +- Session management requires header visibility +- OAuth flows work naturally in browsers + +**Configuration for Browser Clients**: +```yaml +cors: + allow_origins: ["https://your-web-app.com"] # Your web app's domain + allow_credentials: true # Required for cookies/auth + expose_headers: # Headers browser can access + - "MCP-Session-ID" # Session tracking + - "X-Request-ID" # Request correlation + - "X-Rate-Limit-Remaining" # Usage tracking +``` + +**What This Enables**: +- **Session Persistence**: Browser can read and store `MCP-Session-ID` +- **Request Tracking**: Correlate requests with `X-Request-ID` +- **Rate Limit Awareness**: Display usage with rate limit headers + +**Example Browser Client**: +```javascript +// Browser can now access exposed headers +const response = await fetch('https://gateway.example.com/service/mcp', { + headers: { + 'Authorization': 'Bearer ' + accessToken, + 'MCP-Protocol-Version': '2025-06-18' + } +}); + +// Access exposed headers +const sessionId = response.headers.get('MCP-Session-ID'); +const requestId = response.headers.get('X-Request-ID'); +``` + +**Security Note**: Only expose headers that browser clients need. Avoid exposing sensitive internal headers. + ## Why These Decisions? **Focus**: Do one thing well - transparent OAuth for MCP HTTP services diff --git a/src/config/config.py b/src/config/config.py index c44c0c5..5ad1f86 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -44,6 +44,7 @@ class CorsConfig: default_factory=lambda: ["GET", "POST", "PUT", "DELETE", "OPTIONS"] ) allow_headers: List[str] = field(default_factory=lambda: ["*"]) + expose_headers: List[str] = field(default_factory=list) @dataclass @@ -286,6 +287,7 @@ def load_config(self) -> GatewayConfig: "allow_methods", ["GET", "POST", "PUT", "DELETE", "OPTIONS"] ), allow_headers=cors_data.get("allow_headers", ["*"]), + expose_headers=cors_data.get("expose_headers", []), ) # Parse Storage configuration @@ -381,6 +383,7 @@ def save_config(self) -> None: "allow_credentials": self.config.cors.allow_credentials, "allow_methods": self.config.cors.allow_methods, "allow_headers": self.config.cors.allow_headers, + "expose_headers": self.config.cors.expose_headers, }, "oauth_providers": {}, "mcp_services": {}, diff --git a/src/gateway.py b/src/gateway.py index fc066ef..0805629 100644 --- a/src/gateway.py +++ b/src/gateway.py @@ -263,6 +263,7 @@ def _setup_middleware(self): allow_credentials=self.config.cors.allow_credentials, allow_methods=self.config.cors.allow_methods, allow_headers=self.config.cors.allow_headers, + expose_headers=self.config.cors.expose_headers, ) def _setup_routes(self): diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 3c0863f..f4563d6 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -95,6 +95,7 @@ def test_load_config_from_yaml(self): allow_credentials: false allow_methods: ["GET", "POST"] allow_headers: ["Authorization", "Content-Type"] + expose_headers: ["MCP-Session-ID", "X-Request-ID"] oauth_providers: github: @@ -134,6 +135,7 @@ def test_load_config_from_yaml(self): assert config.cors.allow_credentials is False assert config.cors.allow_methods == ["GET", "POST"] assert config.cors.allow_headers == ["Authorization", "Content-Type"] + assert config.cors.expose_headers == ["MCP-Session-ID", "X-Request-ID"] # Check OAuth provider assert "github" in config.oauth_providers @@ -597,6 +599,7 @@ def test_cors_config_defaults(self): assert config.allow_credentials is True assert config.allow_methods == ["GET", "POST", "PUT", "DELETE", "OPTIONS"] assert config.allow_headers == ["*"] + assert config.expose_headers == [] def test_gateway_config_defaults(self): """Test gateway config with defaults."""