Skip to content

Commit 6393ab0

Browse files
authored
Merge pull request #16 from contextforge-org/Run-15
feat: `cforge run`
2 parents c309171 + 494d368 commit 6393ab0

5 files changed

Lines changed: 1034 additions & 1 deletion

File tree

cforge/commands/server/run.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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()

cforge/main.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from cforge.common import get_app
3333
from cforge.commands.deploy.deploy import deploy
3434
from cforge.commands.server.serve import serve
35+
from cforge.commands.server.run import run
3536
from cforge.commands.settings import profiles
3637
from cforge.commands.settings.login import login
3738
from cforge.commands.settings.logout import logout
@@ -101,10 +102,11 @@
101102
app = get_app()
102103

103104
# ---------------------------------------------------------------------------
104-
# Server command
105+
# Server commands
105106
# ---------------------------------------------------------------------------
106107

107108
app.command(rich_help_panel="Server")(serve)
109+
app.command(rich_help_panel="Server")(run)
108110

109111
# ---------------------------------------------------------------------------
110112
# Settings commands

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ filterwarnings = [
351351
"ignore: Unclosed <MemoryObjectReceiveStream.*:ResourceWarning", # From async client calls
352352
"ignore: Support for class-based `config` is deprecated.*", # Pydantic upgraded needed upstream
353353
"ignore: `regex` has been deprecated, please use `pattern` instead", # FastAPI upgraded needed upstream
354+
"ignore: Extra environment options are deprecated. Use a preconfigured jinja2.Environment instead.", # Jinja2 deprecation upstream
354355
]
355356

356357
# ── fawltydeps ─────────────────────────────────────────────────────

0 commit comments

Comments
 (0)