diff --git a/src/mcpo/CLIENT_HEADER_FORWARDING.md b/src/mcpo/CLIENT_HEADER_FORWARDING.md new file mode 100644 index 0000000..1c85d8e --- /dev/null +++ b/src/mcpo/CLIENT_HEADER_FORWARDING.md @@ -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 ` +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. diff --git a/src/mcpo/utils/main.py b/src/mcpo/utils/main.py index 32c7413..9c110bb 100644 --- a/src/mcpo/utils/main.py +++ b/src/mcpo/utils/main.py @@ -1,4 +1,5 @@ import json +import copy import traceback from typing import Any, Dict, ForwardRef, List, Optional, Type, Union import logging @@ -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 = [] @@ -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) @@ -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, @@ -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 @@ -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"