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
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.
96 changes: 53 additions & 43 deletions src/mcpo/utils/main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import copy
import traceback
from typing import Any, Dict, ForwardRef, List, Optional, Type, Union
import logging
Expand Down Expand Up @@ -31,14 +32,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 @@ -152,12 +151,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 @@ -273,6 +267,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 "mcpo_headers" in masked and isinstance(masked["mcpo_headers"], dict):
headers = masked["mcpo_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(
session,
Expand All @@ -292,33 +317,25 @@ def get_tool_handler(
def make_endpoint_func(
endpoint_name: str, FormModel, session: ClientSession
): # Parameterized endpoint
async def tool(
form_data: FormModel, request: Request
) -> Union[ResponseModel, Any]:
async def tool(form_data: FormModel, request: Request) -> Union[ResponseModel, Any]:
args = form_data.model_dump(exclude_none=True, by_alias=True)

# Process headers for forwarding if configured
forwarded_headers = {}
if (
client_header_forwarding_config
and client_header_forwarding_config.get("enabled", False)
):
forwarded_headers = process_headers_for_server(
request, client_header_forwarding_config
)

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

# Add headers to _meta if any headers are being forwarded
meta = {}
if forwarded_headers:
meta["headers"] = forwarded_headers

logger.info(f"Calling endpoint: {endpoint_name}, with args: {args}")
args["mcpo_headers"] = forwarded_headers
masked_args = mask_sensitive_headers(args)
logger.info(f"Calling endpoint: {endpoint_name}, with args: {masked_args}")
try:
result = await session.call_tool(endpoint_name, arguments=args)

logger.info(f"{result}")
if result.isError:
error_message = "Unknown tool execution error"
error_data = None # Initialize error_data
error_data = None
if result.content:
if isinstance(result.content[0], types.TextContent):
error_message = result.content[0].text
Expand Down Expand Up @@ -366,29 +383,22 @@ async def tool(
def make_endpoint_func_no_args(
endpoint_name: str, session: ClientSession
): # Parameterless endpoint
async def tool(
request: Request,
): # No parameters but need request for headers
async def tool(request: Request):
# Process headers for forwarding if configured
forwarded_headers = {}
if (
client_header_forwarding_config
and client_header_forwarding_config.get("enabled", False)
):
forwarded_headers = process_headers_for_server(
request, client_header_forwarding_config
)
if client_header_forwarding_config and client_header_forwarding_config.get("enabled", False):
forwarded_headers = process_headers_for_server(request, client_header_forwarding_config)


# Add headers to _meta if any headers are being forwarded
meta = {}
arguments = {}
if forwarded_headers:
meta["headers"] = forwarded_headers

logger.info(f"Calling endpoint: {endpoint_name}, with no args")
arguments["mcpo_headers"] = forwarded_headers
logger.info(f"Calling endpoint: {endpoint_name}, , with no args")
try:
result = await session.call_tool(
endpoint_name, arguments={}
) # Empty dict
result = await session.call_tool(endpoint_name, arguments=arguments)


if result.isError:
error_message = "Unknown tool execution error"
Expand Down