Repository: IBM/mcp-context-forge

Issue:
[Bug]: MCP Gateway Doesn't Detect HTTPS/TLS Context or respect X-Forwarded-Proto when using Federation
# Bug Report: MCP Gateway Doesn't Detect HTTPS/TLS Context and Hardcodes HTTP URLs

## Summary
MCP Gateway hardcodes `http://` protocol in generated URLs and doesn't detect when it's running behind HTTPS (via Gunicorn with TLS or a reverse proxy). While the gateway can be deployed with TLS using Gunicorn's SSL support, internal URL generation still uses `http://` causing protocol mismatches and federation issues.

This ticket suggests adding a `GATEWAY_PROTOCOL=http|https|auto(default)`


## When Does This Bug Affect You?

### 1. **Federation Scenarios** (federation/discovery.py issue)
**Affected when:**
- You have multiple MCP Gateways that need to communicate
- You enable `FEDERATION_ENABLED=true`
- You want gateways to share tools/resources

**Example scenario:**
```
Gateway A (HTTPS) ──federation──> Gateway B (HTTPS)
         ↓
   Generates http:// URL for registration ❌
```

**NOT affected when:**
- Running a single standalone gateway
- Adding MCP servers to the gateway
- Federation is disabled (default)

### What is Federation in MCP Gateway?

Federation is about **connecting multiple MCP Gateways together** to share resources. It's NOT about:
- Adding MCP servers to the gateway (that's just normal server registration)
- Clients accessing the gateway via HTTP/SSE/WebSocket

Federation allows:
- Gateway A to discover Gateway B's tools/resources
- Clients connected to Gateway A to use tools from Gateway B
- Automatic peer discovery and synchronization

### 2. **SSE Client Connections** (sse_transport.py issue)
**Affected when:**
- Clients connect using SSE transport
- The gateway runs with HTTPS
- The SSE endpoint URL sent to clients has wrong protocol

**Example:**
```bash
# Client connects to https://gateway.com/sse
# Gateway responds with endpoint URL: http://gateway.com/message?session_id=xxx ❌
# Should be: https://gateway.com/message?session_id=xxx ✓
```

**NOT affected when:**
- Using HTTP transport (not SSE)
- Using WebSocket transport
- Running everything as HTTP (no TLS)

## Do You Need to Worry?

**Probably NOT if you:**
- Run a single gateway (no federation)
- Use HTTP or WebSocket transport (not SSE)
- Have `FEDERATION_ENABLED=false` (default)
- Run everything as HTTP

**You SHOULD care if you:**
- Want to federate multiple gateways over HTTPS
- Use SSE transport with HTTPS
- Need proper HTTPS URLs in responses

## Quick Check

Run this to see if you're affected:
```bash
# Check the current settings
grep -E "FEDERATION_ENABLED|TRANSPORT_TYPE" .env

# If you see:
# FEDERATION_ENABLED=false  → Not affected by federation bug
# TRANSPORT_TYPE=http       → Not affected by SSE bug
# TRANSPORT_TYPE=ws         → Not affected by SSE bug
```

Most users running a single gateway to aggregate MCP servers won't hit this bug at all!

---

## Current Behavior
When running the gateway with TLS enabled through Gunicorn:
```bash
SSL=true CERT_FILE=certs/cert.pem KEY_FILE=certs/key.pem ./run-gunicorn.sh
```

The gateway:
1. **Successfully serves HTTPS** requests
2. **But generates HTTP URLs** in:
   - Federation discovery: `url = f"http://{addresses[0]}:{port}"`
   - SSE transport base URL: `f"http://{settings.host}:{settings.port}"`
   - Documentation examples
   - Default allowed origins

## Expected Behavior
The gateway should:
1. Detect when it's running with TLS (via Gunicorn SSL or reverse proxy)
2. Generate URLs with the correct protocol (`https://` when using TLS)
3. Support both HTTP and HTTPS deployments without code changes
4. Respect `X-Forwarded-Proto` headers from reverse proxies

## Impact
- **Severity**: Medium
- **Components Affected**: Federation, SSE Transport, URL generation
- **User Impact**: 
  - Federation fails when HTTPS gateway tries to register with `http://` URLs
  - SSE clients receive incorrect endpoint URLs
  - Mixed protocol issues in federated deployments

## Root Cause Analysis

### 1. No Protocol Detection
The gateway doesn't detect:
- When Gunicorn is running with SSL certificates
- `X-Forwarded-Proto` headers from reverse proxies
- The actual protocol being used for incoming requests

### 2. Hardcoded Protocol in URL Generation
Multiple modules hardcode `http://`:

```python
# federation/discovery.py - Line ~211
url = f"http://{addresses[0]}:{port}"

# transports/sse_transport.py - Line ~30
self._base_url = base_url or f"http://{settings.host}:{settings.port}"

# config.py - Default allowed origins
allowed_origins: Set[str] = {
    "http://localhost",
    "http://localhost:4444",
}
```

### 3. Missing Request Context
URL generation happens without access to the current request context, so it can't determine the actual protocol being used.

## Proposed Solutions

### Solution 1: Protocol Detection via Request Context (Recommended)
Detect protocol from the current request and use it for URL generation.

**Implementation Steps:**
1. Pass request context to URL generation methods
2. Check for HTTPS indicators:
   ```python
   def get_protocol_from_request(request: Request) -> str:
       # Check X-Forwarded-Proto header
       forwarded_proto = request.headers.get("X-Forwarded-Proto")
       if forwarded_proto:
           return forwarded_proto
       
       # Check if request is secure
       if request.url.scheme == "https":
           return "https"
       
       # Check for other proxy headers
       if request.headers.get("X-Forwarded-SSL") == "on":
           return "https"
       
       return "http"
   ```

3. Update URL generation to use detected protocol:
   ```python
   # In SSE transport
   def __init__(self, request: Request, base_url: str = None):
       protocol = get_protocol_from_request(request)
       self._base_url = base_url or f"{protocol}://{settings.host}:{settings.port}"
   ```

**Pros:**
- Automatic detection works with any deployment
- No configuration needed
- Works with reverse proxies

**Cons:**
- Requires request context in more places
- May need refactoring

### Solution 2: Configuration-Based Protocol
Add optional protocol configuration that defaults to auto-detection.

**Implementation Steps:**
1. Add configuration:
   ```python
   # Protocol for generated URLs (http, https, or auto)
   gateway_protocol: str = "auto"
   
   @property
   def base_url(self) -> str:
       protocol = self.gateway_protocol
       if protocol == "auto":
           # Default to http, but can be overridden by request context
           protocol = "http"
       return f"{protocol}://{self.host}:{self.port}"
   ```

2. Allow environment override:
   ```bash
   GATEWAY_PROTOCOL=https  # Force HTTPS URLs
   ```

**Pros:**
- Simple to implement
- Explicit control when needed
- Backward compatible

**Cons:**
- Requires manual configuration
- Doesn't handle mixed deployments well

### Solution 3: External URL Configuration
Allow explicit configuration of the external URL.

**Implementation Steps:**
1. Add configuration:
   ```python
   # External URL for the gateway (auto-detected if not set)
   external_url: Optional[str] = None
   
   @property
   def base_url(self) -> str:
       return self.external_url or f"http://{self.host}:{self.port}"
   ```

**Pros:**
- Most flexible
- Handles complex deployments

**Cons:**
- Requires manual configuration
- Another setting to manage

## Recommended Fix

Implement **Solution 1** (Protocol Detection) with Solution 2 as fallback:

1. **Update SSE Transport** to accept request context:
   ```python
   class SSETransport(Transport):
       def __init__(self, request: Request = None, base_url: str = None):
           if base_url:
               self._base_url = base_url
           elif request:
               protocol = get_protocol_from_request(request)
               self._base_url = f"{protocol}://{settings.host}:{settings.port}"
           else:
               # Fallback to configuration
               self._base_url = settings.base_url
   ```

2. **Add Protocol Detection Helper**:
   ```python
   # In utils or transport base
   def get_protocol_from_request(request: Request) -> str:
       """Detect protocol from request headers and context."""
       # Implementation as shown above
   ```

3. **Update Federation Discovery**:
   ```python
   # For DNS-SD, add protocol hint to service properties
   properties={
       "name": settings.app_name,
       "version": "1.0.0",
       "protocol": PROTOCOL_VERSION,
       "scheme": "https" if running_with_tls() else "http",
   }
   ```

4. **Add Optional Configuration**:
   ```python
   # For cases where auto-detection isn't sufficient
   gateway_protocol: str = Field(
       default="auto",
       pattern="^(http|https|auto)$",
       description="Protocol for generated URLs (http, https, or auto)"
   )
   ```

## Workaround
Until fixed, when running with TLS:
1. Ensure federated peers use full HTTPS URLs in `FEDERATION_PEERS`
2. Configure reverse proxy to handle protocol translation
3. Use explicit `base_url` parameter where possible

## Test Cases
1. Gateway with Gunicorn SSL generates HTTPS URLs
2. Gateway behind HTTPS reverse proxy detects protocol correctly
3. Direct HTTP access still generates HTTP URLs
4. Federation between HTTP and HTTPS gateways works correctly
5. SSE endpoint URL matches the protocol used to access the gateway
6. X-Forwarded-Proto header is respected


## References
- Gunicorn SSL support: `run-gunicorn.sh`
- URL generation: `mcpgateway/federation/discovery.py`, `mcpgateway/transports/sse_transport.py`
- Configuration: `mcpgateway/config.py`



Discussion:

Patch:
diff --git a/mcpgateway/main.py b/mcpgateway/main.py
index 7aa7bd61..316af684 100644
--- a/mcpgateway/main.py
+++ b/mcpgateway/main.py
@@ -31,6 +31,7 @@
 import json
 import logging
 from typing import Any, AsyncIterator, Dict, List, Optional, Union
+from urllib.parse import urlparse, urlunparse
 
 # Third-Party
 from fastapi import (
@@ -54,6 +55,7 @@
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.orm import Session
 from starlette.middleware.base import BaseHTTPMiddleware
+from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
 
 # First-Party
 from mcpgateway import __version__
@@ -476,6 +478,9 @@ async def __call__(self, scope, receive, send):
 # Add streamable HTTP middleware for /mcp routes
 app.add_middleware(MCPPathRewriteMiddleware)
 
+# Trust all proxies (or lock down with a list of host patterns)
+app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
+
 
 # Set up Jinja2 templates and store in app state for later use
 templates = Jinja2Templates(directory=str(settings.templates_dir))
@@ -597,6 +602,42 @@ async def invalidate_resource_cache(uri: Optional[str] = None) -> None:
         resource_cache.clear()
 
 
+def get_protocol_from_request(request: Request) -> str:
+    """
+    Return "https" or "http" based on:
+     1) X-Forwarded-Proto (if set by a proxy)
+     2) request.url.scheme  (e.g. when Gunicorn/Uvicorn is terminating TLS)
+
+    Args:
+        request (Request): The FastAPI request object.
+
+    Returns:
+        str: The protocol used for the request, either "http" or "https".
+    """
+    forwarded = request.headers.get("x-forwarded-proto")
+    if forwarded:
+        # may be a comma-separated list; take the first
+        return forwarded.split(",")[0].strip()
+    return request.url.scheme
+
+
+def update_url_protocol(request: Request) -> str:
+    """
+    Update the base URL protocol based on the request's scheme or forwarded headers.
+
+    Args:
+        request (Request): The FastAPI request object.
+
+    Returns:
+        str: The base URL with the correct protocol.
+    """
+    parsed = urlparse(str(request.base_url))
+    proto = get_protocol_from_request(request)
+    new_parsed = parsed._replace(scheme=proto)
+    # urlunparse keeps netloc and path intact
+    return urlunparse(new_parsed).rstrip("/")
+
+
 # Protocol APIs #
 @protocol_router.post("/initialize")
 async def initialize(request: Request, user: str = Depends(require_auth)) -> InitializeResult:
@@ -921,6 +962,8 @@ async def sse_endpoint(request: Request, server_id: str, user: str = Depends(req
         logger.debug(f"User {user} is establishing SSE connection for server {server_id}")
         base_url = str(request.base_url).rstrip("/")
         server_sse_url = f"{base_url}/servers/{server_id}"
+        server_sse_url = update_url_protocol(server_sse_url)
+
         transport = SSETransport(base_url=server_sse_url)
         await transport.connect()
         await session_registry.add_session(transport.session_id, transport)
@@ -2056,6 +2099,8 @@ async def utility_sse_endpoint(request: Request, user: str = Depends(require_aut
     try:
         logger.debug("User %s requested SSE connection", user)
         base_url = str(request.base_url).rstrip("/")
+        base_url = update_url_protocol(base_url)
+
         transport = SSETransport(base_url=base_url)
         await transport.connect()
         await session_registry.add_session(transport.session_id, transport)
diff --git a/run-gunicorn.sh b/run-gunicorn.sh
index 7a24ed4a..e0addfc2 100755
--- a/run-gunicorn.sh
+++ b/run-gunicorn.sh
@@ -82,5 +82,6 @@ exec gunicorn -c gunicorn.config.py \
     --max-requests-jitter "${GUNICORN_MAX_REQUESTS_JITTER}" \
     --access-logfile - \
     --error-logfile - \
+    --forwarded-allow-ips="*" \
     ${SSL_ARGS} \
     "mcpgateway.main:app"


Test patch:
diff --git a/tests/unit/mcpgateway/test_main.py b/tests/unit/mcpgateway/test_main.py
index f16e3526..ccf37911 100644
--- a/tests/unit/mcpgateway/test_main.py
+++ b/tests/unit/mcpgateway/test_main.py
@@ -909,6 +909,7 @@ async def dummy_post(*_args, **_kwargs):
             response = json.loads(data)
             assert response == {"jsonrpc": "2.0", "id": 1, "result": {}}
 
+    @patch("mcpgateway.main.update_url_protocol", new=lambda url: url)
     @patch("mcpgateway.main.session_registry.add_session")
     @patch("mcpgateway.main.session_registry.respond")
     @patch("mcpgateway.main.SSETransport")