Skip to main content
Glama
__main__.py21.7 kB
# # MCP Foxxy Bridge - Main Entry Point # # Copyright (C) 2024 Billy Bryant # Portions copyright (C) 2024 Sergey Parfenyuk (original MIT-licensed author) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <https://www.gnu.org/licenses/>. # # MIT License attribution: Portions of this file were originally licensed # under the MIT License by Sergey Parfenyuk (2024). # """The entry point for the mcp-foxxy-bridge application. It sets up the logging and runs the main function. Two ways to run the application: 1. Run the application as a module `uv run -m mcp_foxxy_bridge` 2. Run the application as a package `uv run mcp-foxxy-bridge` """ import argparse import asyncio import json import logging import os import shlex import shutil import sys import typing as t from importlib.metadata import version from pathlib import Path from mcp.client.stdio import StdioServerParameters from .clients.sse_client import run_sse_client from .clients.streamablehttp_client import run_streamablehttp_client from .config.config_loader import ( BridgeConfiguration, load_bridge_config_from_file, load_named_server_configs_from_file, ) from .server.mcp_server import MCPServerSettings, run_bridge_server from .utils.config_migration import get_config_dir, migrate_config_directory from .utils.logging import setup_logging from .utils.path_security import PathTraversalError, validate_config_dir, validate_config_path # Deprecated env var. Here for backwards compatibility. SSE_URL: t.Final[str | None] = os.getenv( "SSE_URL", None, ) def _setup_argument_parser() -> argparse.ArgumentParser: """Set up and return the argument parser for the MCP proxy.""" parser = argparse.ArgumentParser( description=("Start the MCP proxy in one of two possible modes: as a client or a server."), epilog=( "Examples:\n" " mcp-foxxy-bridge http://localhost:8080/sse\n" " mcp-foxxy-bridge --transport streamablehttp http://localhost:8080/mcp\n" " mcp-foxxy-bridge --headers Authorization 'Bearer YOUR_TOKEN' http://localhost:8080/sse\n" " mcp-foxxy-bridge --port 8080 -- your-command --arg1 value1 --arg2 value2\n" " mcp-foxxy-bridge --named-server fetch 'uvx mcp-server-fetch' --port 8080\n" " mcp-foxxy-bridge your-command --port 8080 -e KEY VALUE " # Line split "-e ANOTHER_KEY ANOTHER_VALUE\n" " mcp-foxxy-bridge your-command --port 8080 --allow-origin='*'\n" ), formatter_class=argparse.RawTextHelpFormatter, ) _add_arguments_to_parser(parser) return parser def _add_arguments_to_parser(parser: argparse.ArgumentParser) -> None: """Add all arguments to the argument parser.""" try: package_version = version("mcp-foxxy-bridge") except Exception: try: # Try to read from VERSION file version_file = Path(__file__).parent.parent.parent / "VERSION" package_version = version_file.read_text().strip() if version_file.exists() else "unknown" except Exception: package_version = "unknown" parser.add_argument( "--version", action="version", version=f"%(prog)s {package_version}", help="Show the version and exit", ) parser.add_argument( "command_or_url", help=( "Command or URL to connect to. When a URL, will run an SSE/StreamableHTTP client. " "Otherwise, if --named-server is not used, this will be the command " "for the default stdio client. If --named-server is used, this argument " "is ignored for stdio mode unless no default server is desired. " "See corresponding options for more details." ), nargs="?", default=SSE_URL, ) client_group = parser.add_argument_group("SSE/StreamableHTTP client options") client_group.add_argument( "-H", "--headers", nargs=2, action="append", metavar=("KEY", "VALUE"), help="Headers to pass to the SSE server. Can be used multiple times.", default=[], ) client_group.add_argument( "--transport", choices=["sse", "streamablehttp"], default="sse", # For backwards compatibility help="The transport to use for the client. Default is SSE.", ) stdio_client_options = parser.add_argument_group("stdio client options") stdio_client_options.add_argument( "args", nargs="*", help=( "Any extra arguments to the command to spawn the default server. Ignored if only named servers are defined." ), ) stdio_client_options.add_argument( "-e", "--env", nargs=2, action="append", metavar=("KEY", "VALUE"), help=( "Environment variables used when spawning the default server. Can be " "used multiple times. For named servers, environment is inherited or " "passed via --pass-environment." ), default=[], ) stdio_client_options.add_argument( "--cwd", default=None, help=( "The working directory to use when spawning the default server process. " "Named servers inherit the proxy's CWD." ), ) stdio_client_options.add_argument( "--pass-environment", action=argparse.BooleanOptionalAction, help="Pass through all environment variables when spawning all server processes.", default=False, ) stdio_client_options.add_argument( "--debug", action=argparse.BooleanOptionalAction, help="Enable debug mode with detailed logging output.", default=False, ) stdio_client_options.add_argument( "--allow-command-substitution", action=argparse.BooleanOptionalAction, help="Enable command substitution in configuration files for operations like $(op read ...).", default=False, ) stdio_client_options.add_argument( "--allow-dangerous-commands", action=argparse.BooleanOptionalAction, help="⚠️ UNSAFE: Allow ANY command without security validation. Testing only!", default=False, ) stdio_client_options.add_argument( "--named-server", action="append", nargs=2, metavar=("NAME", "COMMAND_STRING"), help=( "Define a named stdio server. NAME is for the URL path /servers/NAME/. " "COMMAND_STRING is a single string with the command and its arguments " "(e.g., 'uvx mcp-server-fetch --timeout 10'). " "These servers inherit the proxy's CWD and environment from --pass-environment." ), default=[], dest="named_server_definitions", ) stdio_client_options.add_argument( "--named-server-config", type=str, default=None, metavar="FILE_PATH", help=( "Path to a JSON configuration file for named stdio servers. " "If provided, this will be the exclusive source for named server definitions, " "and any --named-server CLI arguments will be ignored." ), ) stdio_client_options.add_argument( "--bridge-config", type=str, default=os.getenv("MCP_BRIDGE_CONFIG", None), metavar="FILE_PATH", help=( "Path to a bridge configuration file (JSON format). " "If not specified, uses {config_dir}/config.json. " "Can also be set via MCP_BRIDGE_CONFIG environment variable." ), ) stdio_client_options.add_argument( "--config-dir", type=str, default=os.getenv("MCP_CONFIG_DIR", None), metavar="DIRECTORY_PATH", help=( "Root configuration directory. Defaults to ~/.config/foxxy-bridge/ or MCP_CONFIG_DIR " "environment variable. Config file will be {config_dir}/config.json, " "OAuth tokens in {config_dir}/auth/" ), ) mcp_server_group = parser.add_argument_group("SSE server options") mcp_server_group.add_argument( "--port", type=int, default=8080, help="Port to expose an SSE server on. Default is 8080", ) mcp_server_group.add_argument( "--host", default="127.0.0.1", help="Host to expose an SSE server on. Default is 127.0.0.1", ) mcp_server_group.add_argument( "--stateless", action=argparse.BooleanOptionalAction, help="Enable stateless mode for streamable http transports. Default is False", default=False, ) mcp_server_group.add_argument( "--sse-port", type=int, default=0, help="(deprecated) Same as --port", ) mcp_server_group.add_argument( "--sse-host", default="127.0.0.1", help="(deprecated) Same as --host", ) mcp_server_group.add_argument( "--allow-origin", nargs="+", default=[], help=("Allowed origins for the SSE server. Can be used multiple times. Default is no CORS allowed."), ) def _setup_logging(*, debug: bool) -> logging.Logger: """Set up Rich-based logging configuration and return the logger.""" return setup_logging(debug=debug) def _handle_sse_client_mode( args_parsed: argparse.Namespace, logger: logging.Logger, ) -> None: """Handle SSE/StreamableHTTP client mode operation.""" if args_parsed.named_server_definitions: logger.warning( "--named-server arguments are ignored when command_or_url is an HTTP/HTTPS URL " "(SSE/StreamableHTTP client mode).", ) # Start a client connected to the SSE server, and expose as a stdio server logger.debug("Starting SSE/StreamableHTTP client and stdio server") headers = dict(args_parsed.headers) if api_access_token := os.getenv("API_ACCESS_TOKEN", None): headers["Authorization"] = f"Bearer {api_access_token}" if args_parsed.transport == "streamablehttp": asyncio.run(run_streamablehttp_client(args_parsed.command_or_url, headers=headers)) else: asyncio.run(run_sse_client(args_parsed.command_or_url, headers=headers)) def _configure_default_server( args_parsed: argparse.Namespace, base_env: dict[str, str], logger: logging.Logger, ) -> StdioServerParameters | None: """Configure the default server if applicable.""" if not (args_parsed.command_or_url and not args_parsed.command_or_url.startswith(("http://", "https://"))): return None default_server_env = base_env.copy() default_server_env.update(dict(args_parsed.env)) # Specific env vars for default server default_stdio_params = StdioServerParameters( command=args_parsed.command_or_url, args=args_parsed.args, env=default_server_env, cwd=args_parsed.cwd if args_parsed.cwd else None, ) logger.info( "Configured default server: %s %s", args_parsed.command_or_url, " ".join(args_parsed.args), ) return default_stdio_params def _load_named_servers_from_config( config_path: str, base_env: dict[str, str], logger: logging.Logger, ) -> dict[str, StdioServerParameters]: """Load named server configurations from a file.""" try: return load_named_server_configs_from_file(config_path, base_env) except (FileNotFoundError, json.JSONDecodeError, ValueError): # Specific errors are already logged by the loader function # We log a generic message here before exiting logger.exception( "Failed to load server configurations from %s. Exiting.", config_path, ) sys.exit(1) except Exception: # Catch any other unexpected errors from loader logger.exception( "An unexpected error occurred while loading server configurations from %s. Exiting.", config_path, ) sys.exit(1) def _configure_named_servers_from_cli( named_server_definitions: list[tuple[str, str]], base_env: dict[str, str], logger: logging.Logger, ) -> dict[str, StdioServerParameters]: """Configure named servers from CLI arguments.""" named_stdio_params: dict[str, StdioServerParameters] = {} for name, command_string in named_server_definitions: try: command_parts = shlex.split(command_string) if not command_parts: # Handle empty command_string logger.error("Empty COMMAND_STRING for named server '%s'. Skipping.", name) continue command = command_parts[0] command_args = command_parts[1:] # Named servers inherit base_env (which includes passed-through env) # and use the proxy's CWD. named_stdio_params[name] = StdioServerParameters( command=command, args=command_args, env=base_env.copy(), # Each named server gets a copy of the base env cwd=None, # Named servers run in the proxy's CWD ) logger.info("Configured named server '%s': %s", name, command_string) except IndexError: # Should be caught by the check for empty command_parts logger.exception( "Invalid COMMAND_STRING for named server '%s': '%s'. Must include a command.", name, command_string, ) sys.exit(1) except Exception: logger.exception("Error parsing COMMAND_STRING for named server '%s'", name) sys.exit(1) return named_stdio_params def _create_mcp_settings( args_parsed: argparse.Namespace, bridge_config: "BridgeConfiguration | None" = None, ) -> MCPServerSettings: """Create MCP server settings from parsed arguments and optional bridge config.""" # Priority: CLI args > config file > defaults default_host = "127.0.0.1" default_port = 8080 if bridge_config and bridge_config.bridge: # Use CLI args if provided, otherwise fall back to config file values host = args_parsed.host if args_parsed.host != default_host else bridge_config.bridge.host port = args_parsed.port if args_parsed.port != default_port else bridge_config.bridge.port else: # Fallback to CLI args or deprecated sse_* args host = args_parsed.host if args_parsed.host is not None else args_parsed.sse_host port = args_parsed.port if args_parsed.port is not None else args_parsed.sse_port return MCPServerSettings( bind_host=host, port=port, stateless=args_parsed.stateless, allow_origins=(args_parsed.allow_origin if len(args_parsed.allow_origin) > 0 else None), log_level="DEBUG" if args_parsed.debug else "INFO", ) def main() -> None: """Start the client using asyncio.""" parser = _setup_argument_parser() args_parsed = parser.parse_args() logger = _setup_logging(debug=args_parsed.debug) # Migrate configuration directory if needed migrate_config_directory() # Set command substitution environment variable if flag is provided if args_parsed.allow_command_substitution: os.environ["MCP_ALLOW_COMMAND_SUBSTITUTION"] = "true" # Set dangerous commands flag with prominent warning if args_parsed.allow_dangerous_commands: os.environ["MCP_ALLOW_DANGEROUS_COMMANDS"] = "true" logger.critical("DANGER: UNSAFE MODE ENABLED - Command validation DISABLED!") logger.critical("ANY command can now execute via command substitution") logger.critical("This includes rm, curl uploads, privilege escalation, etc.") logger.critical("Only use this for testing/development environments!") # Handle bridge mode first (takes precedence over all other options) # Determine config directory using centralized utility with security validation if args_parsed.config_dir: try: config_dir = validate_config_dir(args_parsed.config_dir) except (PathTraversalError, ValueError) as e: logger.exception("Invalid config directory path: %s", e) sys.exit(1) else: config_dir = get_config_dir() # Determine config file path if args_parsed.bridge_config: # Explicit config file path provided - validate for security (allow any location) try: config_path = validate_config_path(args_parsed.bridge_config) except (PathTraversalError, ValueError) as e: logger.exception("Invalid bridge config file path: %s", e) sys.exit(1) if not config_path.exists(): logger.error("Bridge configuration file not found: %s", config_path) sys.exit(1) else: # Use default config path: {config_dir}/config.json config_path = config_dir / "config.json" if not config_path.exists(): # Create example config and copy to config.json example_config_path = config_dir / "config.example.json" # Validate paths are within the config directory for security try: # These should be safe since they're constructed from config_dir, # but validate to be absolutely certain validated_example_path = validate_config_path(example_config_path, config_dir) validated_config_path = validate_config_path(config_path, config_dir) except (PathTraversalError, ValueError) as e: logger.exception("Security validation failed for config paths: %s", e) sys.exit(1) # Create basic example config example_config = { "mcpServers": { "filesystem": { "transport": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "./"], } }, "bridge": { "conflictResolution": "namespace", "defaultNamespace": True, "aggregation": {"tools": True, "resources": True, "prompts": True}, }, } # Create config directory if it doesn't exist config_dir.mkdir(parents=True, exist_ok=True) # Write example config securely try: with validated_example_path.open("w", encoding="utf-8") as f: json.dump(example_config, f, indent=2) # Set restrictive permissions validated_example_path.chmod(0o600) except (OSError, ValueError) as e: logger.exception("Failed to write example config: %s", e) sys.exit(1) # Copy example to config.json securely try: shutil.copy2(validated_example_path, validated_config_path) # Set restrictive permissions on the main config file validated_config_path.chmod(0o600) except (OSError, ValueError) as e: logger.exception("Failed to copy config file: %s", e) sys.exit(1) logger.info("Created new configuration directory: %s", config_dir) logger.info("Created example configuration: %s", validated_example_path) logger.info("Created default configuration: %s", validated_config_path) logger.info("You can now edit %s to customize your configuration", validated_config_path) # Use the validated path going forward config_path = validated_config_path config_path_str = str(config_path) logger.info("Starting in bridge mode") # Load bridge configuration bridge_base_env: dict[str, str] = {} if args_parsed.pass_environment: bridge_base_env.update(os.environ) try: # Only pass CLI override if explicitly set to True, otherwise use config file value cli_allow_substitution = ( args_parsed.allow_command_substitution if args_parsed.allow_command_substitution else None ) bridge_config = load_bridge_config_from_file(config_path_str, bridge_base_env, cli_allow_substitution) except Exception: logger.exception("Failed to load bridge configuration") sys.exit(1) # Create MCP server settings and run the bridge server mcp_settings = _create_mcp_settings(args_parsed, bridge_config) # Set OAuth config directory (auth subdirectory of config directory) oauth_config_dir = str(config_dir / "auth") try: asyncio.run(run_bridge_server(mcp_settings, bridge_config, config_path_str, oauth_config_dir)) except KeyboardInterrupt: logger.info("Received interrupt signal, shutting down gracefully...") except Exception: logger.exception("Bridge server error") return if __name__ == "__main__": main()

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/billyjbryant/mcp-foxxy-bridge'

If you have feedback or need assistance with the MCP directory API, please join our Discord server