Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/docker-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
branches:
- main
- dev
- dev-headers
tags:
- v*

Expand Down
151 changes: 151 additions & 0 deletions src/mcpo/CLIENT_HEADER_FORWARDING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# Client Header Forwarding in MCPO

MCPO supports forwarding HTTP headers from incoming client requests to MCP servers. This enables passing user context, authentication tokens, and other request-specific information to your MCP tools.

## Configuration

Add client header forwarding configuration to your MCP server config:

```json
{
"mcpServers": {
"some-mcp": {
"command": "uvx",
"args": ["some-mcp"],
"client_header_forwarding": {
"enabled": true,
"whitelist": ["Authorization", "X-User-*", "X-Request-ID"],
"blacklist": ["Host", "Content-Length"],
"debug_headers": false
}
}
}
}
```

## Configuration Options

- `enabled`: Enable/disable client header forwarding for this server (default: false)
- `whitelist`: List of header patterns to forward (supports wildcards with `*`)
- `blacklist`: List of header patterns to block (takes precedence over whitelist)
- `debug_headers`: Enable debug logging for header processing (default: false)

## Header Pattern Matching

- **Exact match**: `"Authorization"` matches only the `Authorization` header
- **Wildcard match**: `"X-User-*"` matches `X-User-ID`, `X-User-Email`, etc.
- **Global wildcard**: `"*"` matches all headers (use with caution)

## How It Works

1. **Client Request**: A client makes an HTTP request to mcpo with headers like `Authorization: Bearer <token>`
2. **Header Filtering**: Headers are filtered based on whitelist/blacklist rules
3. **MCP Forwarding**: Filtered headers are passed to the MCP server via the `_meta.headers` field in tool calls

## Transport Support

Client header forwarding works with all MCP transport types:
- **stdio**: Headers are passed via `_meta` field in JSON-RPC calls
- **SSE**: Headers are passed via `_meta` field in JSON-RPC calls
- **HTTP**: Headers are passed via `_meta` field in JSON-RPC calls

## Complementary Features

Client header forwarding works alongside mcpo's connection-level headers:

```json
{
"mcpServers": {
"protected-server": {
"type": "sse",
"url": "https://api.example.com/mcp",
"headers": {
"Authorization": "Bearer server-token-123"
},
"client_header_forwarding": {
"enabled": true,
"whitelist": ["Authorization", "X-User-*"]
}
}
}
}
```

- **`headers`**: Static headers for mcpo ↔ MCP server authentication
- **`client_header_forwarding`**: Dynamic headers from client ↔ MCP server

## Security Considerations

- **Whitelist Headers**: Only forward necessary headers to minimize attack surface
- **Blacklist Sensitive Headers**: Block headers like `Host`, `Content-Length`, etc.
- **Debug Mode**: Only enable `debug_headers` in development environments

## MCP Server Integration

Your MCP server can access forwarded headers through the `_meta` field in tool calls:

```python
from mcp.server.fastmcp import FastMCP, Context
from mcp.server.session import ServerSession

mcp = FastMCP(name="Example Server")

@mcp.tool()
async def protected_tool(data: str, ctx: Context[ServerSession, None]) -> str:
# Access forwarded headers
headers = getattr(ctx.request_meta, 'headers', {}) if hasattr(ctx, 'request_meta') else {}

# Check authorization
auth_header = headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
raise ValueError("Missing or invalid authorization")

# Extract user context
user_id = headers.get('X-User-ID', 'unknown')
request_id = headers.get('X-Request-ID', 'unknown')

return f"Protected data for user {user_id} (request: {request_id}): {data}"
```

## Example Use Cases

### 1. User Authentication
```json
{
"client_header_forwarding": {
"enabled": true,
"whitelist": ["Authorization"]
}
}
```

Forward JWT tokens or API keys for user authentication.

### 2. Request Tracing
```json
{
"client_header_forwarding": {
"enabled": true,
"whitelist": ["X-Request-ID", "X-Trace-ID"]
}
}
```

Forward tracing headers for request correlation across services.

### 3. User Context
```json
{
"client_header_forwarding": {
"enabled": true,
"whitelist": ["X-User-*"],
"blacklist": ["X-User-Secret"]
}
}
```

Forward user information while blocking sensitive headers.

## Hot Reload Support

Client header forwarding configurations are automatically reloaded when using mcpo's `--hot-reload` feature. Changes to the configuration file will be applied without restarting the server.
57 changes: 41 additions & 16 deletions src/mcpo/utils/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import json
import copy
import traceback
from typing import Any, Dict, ForwardRef, List, Optional, Type, Union

Expand Down Expand Up @@ -33,14 +34,12 @@

logger = logging.getLogger(__name__)


def normalize_server_type(server_type: str) -> str:
"""Normalize server_type to a standard value."""
if server_type in ["streamable_http", "streamablehttp", "streamable-http"]:
return "streamable-http"
return server_type


def process_tool_response(result: CallToolResult) -> list:
"""Universal response processor for all tool endpoints"""
response = []
Expand Down Expand Up @@ -154,12 +153,7 @@ def _process_schema_property(
temp_schema = dict(prop_schema)
temp_schema["type"] = type_option
type_hint, _ = _process_schema_property(
_model_cache,
temp_schema,
model_name_prefix,
prop_name,
False,
schema_defs=schema_defs,
_model_cache, temp_schema, model_name_prefix, prop_name, False, schema_defs=schema_defs
)
type_hints.append(type_hint)

Expand Down Expand Up @@ -275,6 +269,37 @@ def get_model_fields(form_model_name, properties, required_fields, schema_defs=N

return model_fields

def mask_sensitive_headers(args: dict) -> dict:
"""Masks sensitive header values in logs."""
masked = copy.deepcopy(args)

if "headers" in masked and isinstance(masked["headers"], dict):
headers = masked["headers"]
sensitive_keys = {
"authorization",
"token",
"api-key",
"x-api-key",
"x-auth-token",
"x-authorization",
}

for key in headers:
if key.lower() in sensitive_keys:
value = headers[key]
if isinstance(value, str):
if value.lower().startswith("bearer "):
headers[key] = "Bearer *****"
elif value.lower().startswith("basic "):
headers[key] = "Basic *****"
elif value.lower().startswith("api-key "):
headers[key] = "API-Key *****"
else:
headers[key] = "*****"
elif isinstance(headers[key], dict):
headers[key] = mask_sensitive_headers({"value": headers[key]})["value"]

return masked

def get_tool_handler(
endpoint_name,
Expand Down Expand Up @@ -336,20 +361,20 @@ async def tool(

forwarded_headers = {}
if (
client_header_forwarding_config
client_header_forwarding_config
and client_header_forwarding_config.get("enabled", False)
):
forwarded_headers = process_headers_for_server(
request, client_header_forwarding_config
)

meta = {}
if forwarded_headers:
meta["headers"] = forwarded_headers

logger.info(f"Calling endpoint: {endpoint_name}, with args: {args}")
args["headers"] = forwarded_headers
masked_args = mask_sensitive_headers(args)
logger.info(f"Calling endpoint: {endpoint_name}, with args: {masked_args}")
try:
result = await call_tool_with_reconnect(request, args)
logger.info(f"{result}")

if result.isError:
error_message = "Unknown tool execution error"
Expand Down Expand Up @@ -396,16 +421,16 @@ async def tool(
async def tool(request: Request):
forwarded_headers = {}
if (
client_header_forwarding_config
client_header_forwarding_config
and client_header_forwarding_config.get("enabled", False)
):
forwarded_headers = process_headers_for_server(
request, client_header_forwarding_config
)

meta = {}
arguments = {}
if forwarded_headers:
meta["headers"] = forwarded_headers
arguments["headers"] = forwarded_headers

logger.info(f"Calling endpoint: {endpoint_name}, with no args")
try:
Expand Down