From 019223c46268fd8b49240e11722847ad66e54872 Mon Sep 17 00:00:00 2001 From: Dmitry Gotskin Date: Sun, 26 Oct 2025 09:54:10 +0300 Subject: [PATCH] feat: add dynamic token support via HTTP headers - Add headerToEnv parameter for mapping HTTP headers to environment variables - Implement run_mcp_server_with_dynamic_tokens function for dynamic token handling - Update documentation with dynamic token usage examples - Extend config_example.json with GitHub, Bitrix24, Context7 server examples - Update tests to support new functionality - Improve security by passing tokens via headers instead of static env vars --- README.md | 93 ++++++++++++++---- config_example.json | 37 +++++++- src/mcp_proxy/__main__.py | 10 +- src/mcp_proxy/config_loader.py | 23 ++++- src/mcp_proxy/mcp_server.py | 167 +++++++++++++++++++++++++++++++++ tests/test_config_loader.py | 14 +-- 6 files changed, 308 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 8b64c0f..13a035e 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,7 @@ For Claude Desktop, the configuration entry can look like this: "mcpServers": { "mcp-proxy": { "command": "mcp-proxy", - "args": [ - "http://example.io/sse" - ], + "args": ["http://example.io/sse"], "env": { "API_ACCESS_TOKEN": "access-token" } @@ -182,22 +180,20 @@ The JSON file should follow this structure: "disabled": false, "timeout": 60, "command": "uvx", - "args": [ - "mcp-server-fetch" - ], + "args": ["mcp-server-fetch"], "transportType": "stdio" }, "github": { "timeout": 60, "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-github" - ], + "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "" }, - "transportType": "stdio" + "transportType": "stdio", + "headerToEnv": { + "Authorization": "GITHUB_PERSONAL_ACCESS_TOKEN" + } } } } @@ -207,8 +203,73 @@ The JSON file should follow this structure: - `command`: (Required) The command to execute for the stdio server. - `args`: (Optional) A list of arguments for the command. Defaults to an empty list. - `enabled`: (Optional) If `false`, this server definition will be skipped. Defaults to `true`. +- `env`: (Optional) Static environment variables to pass to the stdio server. +- `headerToEnv`: (Optional) Dynamic mapping of HTTP headers to environment variables. When a request comes in with the specified header, its value will be passed as an environment variable to the stdio server. - `timeout` and `transportType`: These fields are present in standard MCP client configurations but are currently **ignored** by `mcp-proxy` when loading named servers. The transport type is implicitly "stdio". +## Dynamic Token Support + +`mcp-proxy` supports dynamic token extraction from HTTP headers. This allows you to pass authentication tokens with each request instead of hardcoding them in the configuration. + +### How it works + +1. **Configure header mapping**: In your server configuration, add a `headerToEnv` section that maps HTTP header names to environment variable names. +2. **Send requests with headers**: When making requests to your MCP servers, include the required headers with your tokens. +3. **Automatic token extraction**: `mcp-proxy` automatically extracts the tokens from headers and passes them as environment variables to the stdio processes. + +### Example Usage + +**Configuration:** + +```json +{ + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "headerToEnv": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "GITHUB_PERSONAL_ACCESS_TOKEN" + } + } + } +} +``` + +**HTTP Request:** + +```http +GET /servers/github/sse HTTP/1.1 +Host: localhost:8080 +GITHUB_PERSONAL_ACCESS_TOKEN: ghp_1234567890abcdef +``` + +**Result:** The GitHub MCP server will receive `GITHUB_PERSONAL_ACCESS_TOKEN=ghp_1234567890abcdef` as an environment variable. + +### Multiple Tokens + +You can configure multiple header mappings for a single server: + +```json +{ + "mcpServers": { + "bitrix": { + "command": "python3", + "args": ["mcp-bitrix/mcp_server.py"], + "headerToEnv": { + "Authorization": "BITRIX_WEBHOOK_URL", + "X-Bitrix-Signature": "BITRIX_ACCESS_TOKEN" + } + } + } +} +``` + +### Security Considerations + +- **Token validation**: Consider implementing token validation in your MCP servers. +- **HTTPS only**: Always use HTTPS in production to protect tokens in transit. +- **Token rotation**: Implement token rotation mechanisms for enhanced security. + ## Installation ### Installing via PyPI @@ -253,6 +314,7 @@ docker run --rm -t ghcr.io/sparfenyuk/mcp-proxy:v0.3.2-alpine --help **Solution**: Try to use the full path to the binary. To do so, open a terminal and run the command`which mcp-proxy` ( macOS, Linux) or `where.exe mcp-proxy` (Windows). Then, use the output path as a value for 'command' attribute: + ```json "fetch": { "command": "/full/path/to/bin/mcp-proxy", @@ -374,18 +436,13 @@ Examples: "enabled": true, "timeout": 60, "command": "uvx", - "args": [ - "mcp-server-fetch" - ], + "args": ["mcp-server-fetch"], "transportType": "stdio" }, "github": { "timeout": 60, "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-github" - ], + "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "" }, diff --git a/config_example.json b/config_example.json index 760f055..d72534d 100644 --- a/config_example.json +++ b/config_example.json @@ -10,14 +10,45 @@ "transportType": "stdio" }, "github": { + "enabled": true, + "timeout": 60, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github", + "--toolsets", + "repos,issues,context,projects,gists" + ], + "transportType": "stdio", + "headerToEnv": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "GITHUB_PERSONAL_ACCESS_TOKEN" + } + }, + "bitrix24": { + "enabled": false, + "timeout": 60, + "command": "python3", + "args": [ + "mcp-bitrix/mcp_server.py" + ], + "transportType": "stdio", + "headerToEnv": { + "BITRIX_WEBHOOK_URL": "BITRIX_WEBHOOK_URL", + "BITRIX_ACCESS_TOKEN": "BITRIX_ACCESS_TOKEN" + } + }, + "context7": { "enabled": false, "timeout": 60, "command": "npx", "args": [ "-y", - "@modelcontextprotocol/server-github" + "@upstash/context7-mcp" ], - "transportType": "stdio" + "transportType": "stdio", + "headerToEnv": { + "CONTEXT7_API_KEY": "CONTEXT7_API_KEY" + } } } -} +} \ No newline at end of file diff --git a/src/mcp_proxy/__main__.py b/src/mcp_proxy/__main__.py index 796db7f..18a62d3 100644 --- a/src/mcp_proxy/__main__.py +++ b/src/mcp_proxy/__main__.py @@ -19,7 +19,7 @@ from mcp.client.stdio import StdioServerParameters from .config_loader import load_named_server_configs_from_file -from .mcp_server import MCPServerSettings, run_mcp_server +from .mcp_server import MCPServerSettings, run_mcp_server_with_dynamic_tokens from .sse_client import run_sse_client from .streamablehttp_client import run_streamablehttp_client @@ -278,7 +278,7 @@ def _load_named_servers_from_config( config_path: str, base_env: dict[str, str], logger: logging.Logger, -) -> dict[str, StdioServerParameters]: +) -> tuple[dict[str, StdioServerParameters], dict[str, dict[str, str]]]: """Load named server configurations from a file.""" try: return load_named_server_configs_from_file(config_path, base_env) @@ -388,12 +388,13 @@ def main() -> None: # Configure named servers named_stdio_params: dict[str, StdioServerParameters] = {} + header_mappings: dict[str, dict[str, str]] = {} if args_parsed.named_server_config: if args_parsed.named_server_definitions: logger.warning( "--named-server CLI arguments are ignored when --named-server-config is provided.", ) - named_stdio_params = _load_named_servers_from_config( + named_stdio_params, header_mappings = _load_named_servers_from_config( args_parsed.named_server_config, base_env, logger, @@ -416,9 +417,10 @@ def main() -> None: # Create MCP server settings and run the server mcp_settings = _create_mcp_settings(args_parsed) asyncio.run( - run_mcp_server( + run_mcp_server_with_dynamic_tokens( default_server_params=default_stdio_params, named_server_params=named_stdio_params, + header_mappings=header_mappings, mcp_settings=mcp_settings, ), ) diff --git a/src/mcp_proxy/config_loader.py b/src/mcp_proxy/config_loader.py index f11657c..fc2856f 100644 --- a/src/mcp_proxy/config_loader.py +++ b/src/mcp_proxy/config_loader.py @@ -15,7 +15,7 @@ def load_named_server_configs_from_file( config_file_path: str | Path, base_env: dict[str, str], -) -> dict[str, StdioServerParameters]: +) -> tuple[dict[str, StdioServerParameters], dict[str, dict[str, str]]]: """Loads named server configurations from a JSON file. Args: @@ -23,7 +23,9 @@ def load_named_server_configs_from_file( base_env: The base environment dictionary to be inherited by servers. Returns: - A dictionary of named server parameters. + A tuple containing: + - A dictionary of named server parameters + - A dictionary of header-to-environment mappings for each server Raises: FileNotFoundError: If the config file is not found. @@ -31,6 +33,7 @@ def load_named_server_configs_from_file( ValueError: If the config file format is invalid. """ named_stdio_params: dict[str, StdioServerParameters] = {} + header_mappings: dict[str, dict[str, str]] = {} logger.info("Loading named server configurations from: %s", config_file_path) try: @@ -70,6 +73,7 @@ def load_named_server_configs_from_file( command = server_config.get("command") command_args = server_config.get("args", []) env = server_config.get("env", {}) + header_to_env = server_config.get("headerToEnv", {}) if not command: logger.warning( @@ -83,6 +87,12 @@ def load_named_server_configs_from_file( name, ) continue + if not isinstance(header_to_env, dict): + logger.warning( + "Named server '%s' from config has invalid 'headerToEnv' (must be a dict). Skipping.", + name, + ) + continue new_env = base_env.copy() new_env.update(env) @@ -93,11 +103,16 @@ def load_named_server_configs_from_file( env=new_env, cwd=None, ) + + # Store header mapping for this server + header_mappings[name] = header_to_env + logger.info( - "Configured named server '%s' from config: %s %s", + "Configured named server '%s' from config: %s %s (header mappings: %s)", name, command, " ".join(command_args), + list(header_to_env.keys()) if header_to_env else "none", ) - return named_stdio_params + return named_stdio_params, header_mappings diff --git a/src/mcp_proxy/mcp_server.py b/src/mcp_proxy/mcp_server.py index bfe1d91..215a986 100644 --- a/src/mcp_proxy/mcp_server.py +++ b/src/mcp_proxy/mcp_server.py @@ -22,6 +22,8 @@ from starlette.types import Receive, Scope, Send from .proxy_server import create_proxy_server +from .token_middleware import TokenExtractionMiddleware +from .dynamic_stdio_manager import DynamicStdioManager logger = logging.getLogger(__name__) @@ -138,14 +140,169 @@ async def handle_streamable_http_instance(scope: Scope, receive: Receive, send: return routes, http_session_manager +async def run_mcp_server_with_dynamic_tokens( + mcp_settings: MCPServerSettings, + default_server_params: StdioServerParameters | None = None, + named_server_params: dict[str, StdioServerParameters] | None = None, + header_mappings: dict[str, dict[str, str]] | None = None, +) -> None: + """Run stdio client(s) with dynamic token support and expose an MCP server.""" + if named_server_params is None: + named_server_params = {} + if header_mappings is None: + header_mappings = {} + + # Initialize dynamic stdio manager + stdio_manager = DynamicStdioManager() + + all_routes: list[BaseRoute] = [ + Route("/status", endpoint=_handle_status), # Global status endpoint + ] + + # Use AsyncExitStack to manage lifecycles of multiple components + async with contextlib.AsyncExitStack() as stack: + # Manage lifespans of all StreamableHTTPSessionManagers + @contextlib.asynccontextmanager + async def combined_lifespan(_app: Starlette) -> AsyncIterator[None]: + logger.info("Main application lifespan starting...") + # Register servers with the dynamic manager + if default_server_params: + await stdio_manager.register_server("default", default_server_params) + await stdio_manager.start_server("default") + + for name, params in named_server_params.items(): + await stdio_manager.register_server(name, params) + await stdio_manager.start_server(name) + + yield + + logger.info("Main application lifespan shutting down...") + await stdio_manager.stop_all_servers() + + # Setup default server if configured + if default_server_params: + logger.info( + "Setting up default server: %s %s", + default_server_params.command, + " ".join(default_server_params.args), + ) + + # Get session from dynamic manager + session = stdio_manager.get_server_session("default") + if session: + proxy = await create_proxy_server(session) + + instance_routes, http_manager = create_single_instance_routes( + proxy, + stateless_instance=mcp_settings.stateless, + ) + await stack.enter_async_context(http_manager.run()) + all_routes.extend(instance_routes) + _global_status["server_instances"]["default"] = "configured" + + # Setup named servers + for name, params in named_server_params.items(): + logger.info( + "Setting up named server '%s': %s %s", + name, + params.command, + " ".join(params.args), + ) + + # Get session from dynamic manager + session = stdio_manager.get_server_session(name) + if session: + proxy = await create_proxy_server(session) + + instance_routes, http_manager = create_single_instance_routes( + proxy, + stateless_instance=mcp_settings.stateless, + ) + await stack.enter_async_context(http_manager.run()) + + # Mount these routes under /servers// + server_mount = Mount(f"/servers/{name}", routes=instance_routes) + all_routes.append(server_mount) + _global_status["server_instances"][name] = "configured" + + if not default_server_params and not named_server_params: + logger.error("No servers configured to run.") + return + + middleware: list[Middleware] = [] + + # Add token extraction middleware if header mappings are provided + if header_mappings: + middleware.append( + Middleware( + TokenExtractionMiddleware, + header_mappings=header_mappings, + ), + ) + + if mcp_settings.allow_origins: + middleware.append( + Middleware( + CORSMiddleware, + allow_origins=mcp_settings.allow_origins, + allow_methods=["*"], + allow_headers=["*"], + ), + ) + + starlette_app = Starlette( + debug=(mcp_settings.log_level == "DEBUG"), + routes=all_routes, + middleware=middleware, + lifespan=combined_lifespan, + ) + + starlette_app.router.redirect_slashes = False + + config = uvicorn.Config( + starlette_app, + host=mcp_settings.bind_host, + port=mcp_settings.port, + log_level=mcp_settings.log_level.lower(), + ) + http_server = uvicorn.Server(config) + + # Print out the SSE URLs for all configured servers + base_url = f"http://{mcp_settings.bind_host}:{mcp_settings.port}" + sse_urls = [] + + # Add default server if configured + if default_server_params: + sse_urls.append(f"{base_url}/sse") + + # Add named servers + sse_urls.extend([f"{base_url}/servers/{name}/sse" for name in named_server_params]) + + # Display the SSE URLs prominently + if sse_urls: + logger.info("Serving MCP Servers via SSE:") + for url in sse_urls: + logger.info(" - %s", url) + + logger.debug( + "Serving incoming MCP requests on %s:%s", + mcp_settings.bind_host, + mcp_settings.port, + ) + await http_server.serve() + + async def run_mcp_server( mcp_settings: MCPServerSettings, default_server_params: StdioServerParameters | None = None, named_server_params: dict[str, StdioServerParameters] | None = None, + header_mappings: dict[str, dict[str, str]] | None = None, ) -> None: """Run stdio client(s) and expose an MCP server with multiple possible backends.""" if named_server_params is None: named_server_params = {} + if header_mappings is None: + header_mappings = {} all_routes: list[BaseRoute] = [ Route("/status", endpoint=_handle_status), # Global status endpoint @@ -209,6 +366,16 @@ async def combined_lifespan(_app: Starlette) -> AsyncIterator[None]: return middleware: list[Middleware] = [] + + # Add token extraction middleware if header mappings are provided + if header_mappings: + middleware.append( + Middleware( + TokenExtractionMiddleware, + header_mappings=header_mappings, + ), + ) + if mcp_settings.allow_origins: middleware.append( Middleware( diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index 29899e2..99033a8 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -57,7 +57,7 @@ def test_load_valid_config(create_temp_config_file: Callable[[dict[str, t.Any]], base_env = {"PASSED": "env_value"} base_env_with_added_env = {"PASSED": "env_value", "FOO": "bar"} - loaded_params = load_named_server_configs_from_file(tmp_config_path, base_env) + loaded_params, header_mappings = load_named_server_configs_from_file(tmp_config_path, base_env) assert "server1" in loaded_params assert loaded_params["server1"].command == "echo" @@ -85,7 +85,7 @@ def test_load_config_with_not_enabled_server( }, } tmp_config_path = create_temp_config_file(config_content) - loaded_params = load_named_server_configs_from_file(tmp_config_path, {}) + loaded_params, header_mappings = load_named_server_configs_from_file(tmp_config_path, {}) assert "explicitly_enabled_server" in loaded_params assert loaded_params["explicitly_enabled_server"].command == "true_command" @@ -135,7 +135,7 @@ def test_load_example_fetch_config_if_uvx_exists() -> None: ) base_env = {"EXAMPLE_ENV": "true"} - loaded_params = load_named_server_configs_from_file(example_config_path, base_env) + loaded_params, header_mappings = load_named_server_configs_from_file(example_config_path, base_env) assert "fetch" in loaded_params fetch_param = loaded_params["fetch"] @@ -167,7 +167,7 @@ def test_invalid_server_entry_not_dict( config_content = {"mcpServers": {"server1": "not_a_dict"}} tmp_config_path = create_temp_config_file(config_content) - loaded_params = load_named_server_configs_from_file(tmp_config_path, {}) + loaded_params, header_mappings = load_named_server_configs_from_file(tmp_config_path, {}) assert len(loaded_params) == 0 # No servers should be loaded mock_logger.warning.assert_called_with( "Skipping invalid server config for '%s' in %s. Entry is not a dictionary.", @@ -184,7 +184,7 @@ def test_server_entry_missing_command( """Test handling of server entries missing the command field.""" config_content = {"mcpServers": {"server_no_command": {"args": ["arg1"]}}} tmp_config_path = create_temp_config_file(config_content) - loaded_params = load_named_server_configs_from_file(tmp_config_path, {}) + loaded_params, header_mappings = load_named_server_configs_from_file(tmp_config_path, {}) assert "server_no_command" not in loaded_params mock_logger.warning.assert_called_with( "Named server '%s' from config is missing 'command'. Skipping.", @@ -204,7 +204,7 @@ def test_server_entry_invalid_args_type( }, } tmp_config_path = create_temp_config_file(config_content) - loaded_params = load_named_server_configs_from_file(tmp_config_path, {}) + loaded_params, header_mappings = load_named_server_configs_from_file(tmp_config_path, {}) assert "server_invalid_args" not in loaded_params mock_logger.warning.assert_called_with( "Named server '%s' from config has invalid 'args' (must be a list). Skipping.", @@ -216,7 +216,7 @@ def test_empty_mcpservers_dict(create_temp_config_file: Callable[[dict[str, t.An """Test handling of configuration files with empty mcpServers dictionary.""" config_content: dict[str, t.Any] = {"mcpServers": {}} tmp_config_path = create_temp_config_file(config_content) - loaded_params = load_named_server_configs_from_file(tmp_config_path, {}) + loaded_params, header_mappings = load_named_server_configs_from_file(tmp_config_path, {}) assert len(loaded_params) == 0