|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | +"""Location: ./cforge/commands/server/run.py |
| 3 | +Copyright 2025 |
| 4 | +SPDX-License-Identifier: Apache-2.0 |
| 5 | +Authors: Gabe Goodhart |
| 6 | +
|
| 7 | +CLI command: run |
| 8 | +
|
| 9 | +Run MCP servers locally and expose them via SSE or streamable HTTP protocols. |
| 10 | +This command wraps the mcpgateway.translate functionality to provide a unified |
| 11 | +interface for running and exposing MCP servers. |
| 12 | +""" |
| 13 | + |
| 14 | +# Standard |
| 15 | +import atexit |
| 16 | +import multiprocessing |
| 17 | +import os |
| 18 | +import time |
| 19 | +from typing import List, Optional |
| 20 | + |
| 21 | +# Third-Party |
| 22 | +import requests |
| 23 | +import typer |
| 24 | + |
| 25 | +# First-Party |
| 26 | +from cforge.common import get_console, make_authenticated_request |
| 27 | + |
| 28 | + |
| 29 | +def run( |
| 30 | + stdio: Optional[str] = typer.Option(None, "--stdio", help='Local command to run, e.g. "uvx mcp-server-git"'), |
| 31 | + grpc: Optional[str] = typer.Option(None, "--grpc", help="gRPC server target (host:port) to expose"), |
| 32 | + expose_sse: bool = typer.Option(False, "--expose-sse", help="Expose via SSE protocol (endpoints: /sse and /message)"), |
| 33 | + expose_streamable_http: bool = typer.Option(False, "--expose-streamable-http", help="Expose via streamable HTTP protocol (endpoint: /mcp)"), |
| 34 | + grpc_tls: bool = typer.Option(False, "--grpc-tls", help="Enable TLS for gRPC connection"), |
| 35 | + grpc_cert: Optional[str] = typer.Option(None, "--grpc-cert", help="Path to TLS certificate for gRPC"), |
| 36 | + grpc_key: Optional[str] = typer.Option(None, "--grpc-key", help="Path to TLS key for gRPC"), |
| 37 | + grpc_metadata: Optional[List[str]] = typer.Option(None, "--grpc-metadata", help="gRPC metadata (KEY=VALUE, repeatable)"), |
| 38 | + port: int = typer.Option(8000, "--port", help="HTTP port to bind"), |
| 39 | + host: str = typer.Option("127.0.0.1", "--host", help="Host interface to bind (default: 127.0.0.1)"), |
| 40 | + log_level: str = typer.Option( |
| 41 | + "info", |
| 42 | + "--log-level", |
| 43 | + help="Log level (debug, info, warning, error, critical)", |
| 44 | + ), |
| 45 | + cors: Optional[List[str]] = typer.Option(None, "--cors", help="CORS allowed origins (e.g., --cors https://app.example.com)"), |
| 46 | + oauth2_bearer: Optional[str] = typer.Option(None, "--oauth2-bearer", help="OAuth2 Bearer token for authentication"), |
| 47 | + sse_path: str = typer.Option("/sse", "--sse-path", help="SSE endpoint path (default: /sse)"), |
| 48 | + message_path: str = typer.Option("/message", "--message-path", help="Message endpoint path (default: /message)"), |
| 49 | + keep_alive: int = typer.Option(30, "--keep-alive", help="Keep-alive interval in seconds (default: 30)"), |
| 50 | + stdio_command: Optional[str] = typer.Option( |
| 51 | + None, |
| 52 | + "--stdio-command", |
| 53 | + help="Command to run when bridging SSE/streamableHttp to stdio (optional with --connect-sse or --connect-streamable-http)", |
| 54 | + ), |
| 55 | + enable_dynamic_env: bool = typer.Option(False, "--enable-dynamic-env", help="Enable dynamic environment variable injection from HTTP headers"), |
| 56 | + header_to_env: Optional[List[str]] = typer.Option( |
| 57 | + None, |
| 58 | + "--header-to-env", |
| 59 | + help="Map HTTP header to environment variable (format: HEADER=ENV_VAR, can be used multiple times)", |
| 60 | + ), |
| 61 | + stateless: bool = typer.Option(False, "--stateless", help="Use stateless mode for streamable HTTP (default: False)"), |
| 62 | + json_response: bool = typer.Option(False, "--json-response", help="Return JSON responses instead of SSE streams for streamable HTTP (default: False)"), |
| 63 | + register: bool = typer.Option(True, "--register/--no-register", help="Auto-register the server with the configured Context Forge gateway (default: True)"), |
| 64 | + register_timeout: float = typer.Option(10.0, "--register-timeout", help="Timeout for registration health check (default 10s)"), |
| 65 | + temporary: bool = typer.Option(False, "--temporary", help="Unregister the server on exit (only applies if --register is enabled)"), |
| 66 | + server_name: Optional[str] = typer.Option(None, "--server-name", help="Name for the registered server (auto-generated if not provided)"), |
| 67 | + server_description: Optional[str] = typer.Option(None, "--server-description", help="Description for the registered server"), |
| 68 | +) -> None: |
| 69 | + """Run MCP servers locally and expose them via SSE or streamable HTTP. |
| 70 | +
|
| 71 | + This command bridges between different MCP transport protocols: stdio/JSON-RPC, |
| 72 | + HTTP/SSE, and streamable HTTP. It enables exposing local MCP servers over HTTP |
| 73 | + or consuming remote endpoints as local stdio servers. |
| 74 | +
|
| 75 | + By default, the server is automatically registered with the configured Context Forge |
| 76 | + gateway. Use --no-register to disable this behavior, or --temporary to automatically |
| 77 | + unregister the server when it exits. |
| 78 | +
|
| 79 | + Examples: |
| 80 | +
|
| 81 | + # Expose a local MCP server via SSE (auto-registered) |
| 82 | + cforge run --stdio "uvx mcp-server-git" --port 9000 |
| 83 | +
|
| 84 | + # Expose without registering with the gateway |
| 85 | + cforge run --stdio "uvx mcp-server-git" --port 9000 --no-register |
| 86 | +
|
| 87 | + # Expose and auto-cleanup on exit |
| 88 | + cforge run --stdio "uvx mcp-server-git" --port 9000 --temporary |
| 89 | +
|
| 90 | + # Expose via both SSE and streamable HTTP |
| 91 | + cforge run --stdio "uvx mcp-server-git" --expose-sse --expose-streamable-http --port 9000 |
| 92 | + """ |
| 93 | + console = get_console() |
| 94 | + |
| 95 | + # Handle registration if enabled |
| 96 | + if register and not temporary: |
| 97 | + # Validate that we have something to register |
| 98 | + if not stdio and not grpc: |
| 99 | + console.print("[yellow]Warning: --register requires either --stdio or --grpc to be specified[/yellow]") |
| 100 | + register = False |
| 101 | + |
| 102 | + # Build argument list for translate_main |
| 103 | + args = [] |
| 104 | + |
| 105 | + # Source/destination options (only if provided) |
| 106 | + if stdio is not None: |
| 107 | + args.extend(["--stdio", stdio]) |
| 108 | + if grpc is not None: |
| 109 | + args.extend(["--grpc", grpc]) |
| 110 | + |
| 111 | + # Protocol exposure options (only if True) |
| 112 | + if expose_sse: |
| 113 | + args.append("--expose-sse") |
| 114 | + if expose_streamable_http: |
| 115 | + args.append("--expose-streamable-http") |
| 116 | + |
| 117 | + # gRPC configuration (only if provided) |
| 118 | + if grpc_tls: |
| 119 | + args.append("--grpc-tls") |
| 120 | + if grpc_cert is not None: |
| 121 | + args.extend(["--grpc-cert", grpc_cert]) |
| 122 | + if grpc_key is not None: |
| 123 | + args.extend(["--grpc-key", grpc_key]) |
| 124 | + if grpc_metadata is not None: |
| 125 | + for metadata in grpc_metadata: |
| 126 | + args.extend(["--grpc-metadata", metadata]) |
| 127 | + |
| 128 | + # Server configuration (always pass) |
| 129 | + args.extend(["--port", str(port)]) |
| 130 | + args.extend(["--host", host]) |
| 131 | + args.extend(["--logLevel", log_level]) |
| 132 | + |
| 133 | + # CORS configuration (only if provided) |
| 134 | + if cors is not None: |
| 135 | + args.append("--cors") |
| 136 | + args.extend(cors) |
| 137 | + |
| 138 | + # Authentication (only if provided) |
| 139 | + if oauth2_bearer is not None: |
| 140 | + args.extend(["--oauth2Bearer", oauth2_bearer]) |
| 141 | + |
| 142 | + # SSE configuration (always pass) |
| 143 | + args.extend(["--ssePath", sse_path]) |
| 144 | + args.extend(["--messagePath", message_path]) |
| 145 | + args.extend(["--keepAlive", str(keep_alive)]) |
| 146 | + |
| 147 | + # Stdio command for bridging (only if provided) |
| 148 | + if stdio_command is not None: |
| 149 | + args.extend(["--stdioCommand", stdio_command]) |
| 150 | + |
| 151 | + # Dynamic environment injection (only if enabled) |
| 152 | + if enable_dynamic_env: |
| 153 | + args.append("--enable-dynamic-env") |
| 154 | + if header_to_env is not None: |
| 155 | + for mapping in header_to_env: |
| 156 | + args.extend(["--header-to-env", mapping]) |
| 157 | + |
| 158 | + # Streamable HTTP options (only if True) |
| 159 | + if stateless: |
| 160 | + args.append("--stateless") |
| 161 | + if json_response: |
| 162 | + args.append("--jsonResponse") |
| 163 | + |
| 164 | + # Import top-level translate here to avoid undesirable initialization |
| 165 | + # Third Party |
| 166 | + from mcpgateway.translate import main as translate_main |
| 167 | + |
| 168 | + # Launch the translation wrapper in a subprocess |
| 169 | + proc = multiprocessing.Process(target=translate_main, args=(args,)) |
| 170 | + proc.start() |
| 171 | + |
| 172 | + # Register if requested |
| 173 | + if register: |
| 174 | + |
| 175 | + # Default to SSE if no protocol specified |
| 176 | + is_sse = expose_sse or expose_streamable_http or (not expose_sse and not expose_streamable_http) |
| 177 | + |
| 178 | + registered_server_id: Optional[str] = None |
| 179 | + try: |
| 180 | + # Wait for the server to come up |
| 181 | + server_url_base = f"http://{host}:{port}" |
| 182 | + start_time = time.time() |
| 183 | + ready = False |
| 184 | + while time.time() - start_time <= register_timeout: |
| 185 | + try: |
| 186 | + res = requests.get(f"{server_url_base}/healthz", timeout=0.1) |
| 187 | + if res.status_code == 200: |
| 188 | + ready = True |
| 189 | + break |
| 190 | + except requests.exceptions.ConnectionError: |
| 191 | + time.sleep(0.5) |
| 192 | + if not ready: |
| 193 | + console.print(f"[red]Failed to connect to server in {register_timeout}s[/red]") |
| 194 | + typer.exit(1) |
| 195 | + |
| 196 | + # Build the server URL based on the protocol |
| 197 | + server_url = f"{server_url_base}{sse_path}" if is_sse else f"{server_url_base}/mcp" |
| 198 | + |
| 199 | + # Generate a name if not provided |
| 200 | + if server_name is None: |
| 201 | + if stdio: |
| 202 | + # Extract command name from stdio |
| 203 | + cmd_parts = stdio.split() |
| 204 | + cmd_name = "stdio-server" |
| 205 | + for part in cmd_parts: |
| 206 | + part = os.path.basename(part) |
| 207 | + # Skip known runners, flags, and env vars |
| 208 | + if part.replace("-", "").replace("_", "").isalnum() and not (part.startswith("-") or part in ["docker", "uvx", "npx", "python", "node", "run"] or "=" in part): |
| 209 | + cmd_name = part |
| 210 | + break |
| 211 | + server_name = f"{cmd_name}-{port}" |
| 212 | + elif grpc: |
| 213 | + server_name = f"grpc-{grpc.replace(':', '-')}" |
| 214 | + else: |
| 215 | + server_name = f"server-{port}" |
| 216 | + |
| 217 | + # Build registration payload |
| 218 | + registration_data = { |
| 219 | + "name": server_name, |
| 220 | + "url": server_url, |
| 221 | + "transport": "SSE" if is_sse else "STREAMABLEHTTP", |
| 222 | + } |
| 223 | + |
| 224 | + if server_description: |
| 225 | + registration_data["description"] = server_description |
| 226 | + |
| 227 | + # Register the server |
| 228 | + console.print(f"[cyan]Registering server '{server_name}' at {server_url}...[/cyan]") |
| 229 | + result = make_authenticated_request("POST", "/gateways", json_data=registration_data) |
| 230 | + registered_server_id = result.get("id") |
| 231 | + console.print(f"[green]✓ Server registered successfully (ID: {registered_server_id})[/green]") |
| 232 | + |
| 233 | + # Set up cleanup for temporary servers |
| 234 | + if temporary and registered_server_id: |
| 235 | + |
| 236 | + def cleanup_server(): |
| 237 | + """Unregister the server on exit.""" |
| 238 | + try: |
| 239 | + console.print(f"\n[cyan]Unregistering temporary server (ID: {registered_server_id})...[/cyan]") |
| 240 | + make_authenticated_request("DELETE", f"/gateways/{registered_server_id}") |
| 241 | + console.print("[green]✓ Server unregistered successfully[/green]") |
| 242 | + except Exception as e: |
| 243 | + console.print(f"[yellow]Warning: Failed to unregister server: {e}[/yellow]") |
| 244 | + |
| 245 | + # Register cleanup handlers |
| 246 | + atexit.register(cleanup_server) |
| 247 | + |
| 248 | + except Exception as e: |
| 249 | + console.print(f"[yellow]Warning: Failed to register server: {e}[/yellow]") |
| 250 | + console.print("[yellow]Continuing without registration...[/yellow]") |
| 251 | + |
| 252 | + # Wait for the process to terminate |
| 253 | + proc.join() |
0 commit comments