hud-python 0.4.1__py3-none-any.whl ā 0.4.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of hud-python might be problematic. Click here for more details.
- hud/__init__.py +22 -22
- hud/agents/__init__.py +13 -15
- hud/agents/base.py +599 -599
- hud/agents/claude.py +373 -373
- hud/agents/langchain.py +261 -250
- hud/agents/misc/__init__.py +7 -7
- hud/agents/misc/response_agent.py +82 -80
- hud/agents/openai.py +352 -352
- hud/agents/openai_chat_generic.py +154 -154
- hud/agents/tests/__init__.py +1 -1
- hud/agents/tests/test_base.py +742 -742
- hud/agents/tests/test_claude.py +324 -324
- hud/agents/tests/test_client.py +363 -363
- hud/agents/tests/test_openai.py +237 -237
- hud/cli/__init__.py +617 -617
- hud/cli/__main__.py +8 -8
- hud/cli/analyze.py +371 -371
- hud/cli/analyze_metadata.py +230 -230
- hud/cli/build.py +498 -427
- hud/cli/clone.py +185 -185
- hud/cli/cursor.py +92 -92
- hud/cli/debug.py +392 -392
- hud/cli/docker_utils.py +83 -83
- hud/cli/init.py +280 -281
- hud/cli/interactive.py +353 -353
- hud/cli/mcp_server.py +764 -756
- hud/cli/pull.py +330 -336
- hud/cli/push.py +404 -370
- hud/cli/remote_runner.py +311 -311
- hud/cli/runner.py +160 -160
- hud/cli/tests/__init__.py +3 -3
- hud/cli/tests/test_analyze.py +284 -284
- hud/cli/tests/test_cli_init.py +265 -265
- hud/cli/tests/test_cli_main.py +27 -27
- hud/cli/tests/test_clone.py +142 -142
- hud/cli/tests/test_cursor.py +253 -253
- hud/cli/tests/test_debug.py +453 -453
- hud/cli/tests/test_mcp_server.py +139 -139
- hud/cli/tests/test_utils.py +388 -388
- hud/cli/utils.py +263 -263
- hud/clients/README.md +143 -143
- hud/clients/__init__.py +16 -16
- hud/clients/base.py +378 -379
- hud/clients/fastmcp.py +222 -222
- hud/clients/mcp_use.py +298 -278
- hud/clients/tests/__init__.py +1 -1
- hud/clients/tests/test_client_integration.py +111 -111
- hud/clients/tests/test_fastmcp.py +342 -342
- hud/clients/tests/test_protocol.py +188 -188
- hud/clients/utils/__init__.py +1 -1
- hud/clients/utils/retry_transport.py +160 -160
- hud/datasets.py +327 -322
- hud/misc/__init__.py +1 -1
- hud/misc/claude_plays_pokemon.py +292 -292
- hud/otel/__init__.py +35 -35
- hud/otel/collector.py +142 -142
- hud/otel/config.py +164 -164
- hud/otel/context.py +536 -536
- hud/otel/exporters.py +366 -366
- hud/otel/instrumentation.py +97 -97
- hud/otel/processors.py +118 -118
- hud/otel/tests/__init__.py +1 -1
- hud/otel/tests/test_processors.py +197 -197
- hud/server/__init__.py +5 -5
- hud/server/context.py +114 -114
- hud/server/helper/__init__.py +5 -5
- hud/server/low_level.py +132 -132
- hud/server/server.py +170 -166
- hud/server/tests/__init__.py +3 -3
- hud/settings.py +73 -73
- hud/shared/__init__.py +5 -5
- hud/shared/exceptions.py +180 -180
- hud/shared/requests.py +264 -264
- hud/shared/tests/test_exceptions.py +157 -157
- hud/shared/tests/test_requests.py +275 -275
- hud/telemetry/__init__.py +25 -25
- hud/telemetry/instrument.py +379 -379
- hud/telemetry/job.py +309 -309
- hud/telemetry/replay.py +74 -74
- hud/telemetry/trace.py +83 -83
- hud/tools/__init__.py +33 -33
- hud/tools/base.py +365 -365
- hud/tools/bash.py +161 -161
- hud/tools/computer/__init__.py +15 -15
- hud/tools/computer/anthropic.py +437 -437
- hud/tools/computer/hud.py +376 -376
- hud/tools/computer/openai.py +295 -295
- hud/tools/computer/settings.py +82 -82
- hud/tools/edit.py +314 -314
- hud/tools/executors/__init__.py +30 -30
- hud/tools/executors/base.py +539 -539
- hud/tools/executors/pyautogui.py +621 -621
- hud/tools/executors/tests/__init__.py +1 -1
- hud/tools/executors/tests/test_base_executor.py +338 -338
- hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
- hud/tools/executors/xdo.py +511 -511
- hud/tools/playwright.py +412 -412
- hud/tools/tests/__init__.py +3 -3
- hud/tools/tests/test_base.py +282 -282
- hud/tools/tests/test_bash.py +158 -158
- hud/tools/tests/test_bash_extended.py +197 -197
- hud/tools/tests/test_computer.py +425 -425
- hud/tools/tests/test_computer_actions.py +34 -34
- hud/tools/tests/test_edit.py +259 -259
- hud/tools/tests/test_init.py +27 -27
- hud/tools/tests/test_playwright_tool.py +183 -183
- hud/tools/tests/test_tools.py +145 -145
- hud/tools/tests/test_utils.py +156 -156
- hud/tools/types.py +72 -72
- hud/tools/utils.py +50 -50
- hud/types.py +136 -136
- hud/utils/__init__.py +10 -10
- hud/utils/async_utils.py +65 -65
- hud/utils/design.py +236 -168
- hud/utils/mcp.py +55 -55
- hud/utils/progress.py +149 -149
- hud/utils/telemetry.py +66 -66
- hud/utils/tests/test_async_utils.py +173 -173
- hud/utils/tests/test_init.py +17 -17
- hud/utils/tests/test_progress.py +261 -261
- hud/utils/tests/test_telemetry.py +82 -82
- hud/utils/tests/test_version.py +8 -8
- hud/version.py +7 -7
- {hud_python-0.4.1.dist-info ā hud_python-0.4.3.dist-info}/METADATA +10 -8
- hud_python-0.4.3.dist-info/RECORD +131 -0
- {hud_python-0.4.1.dist-info ā hud_python-0.4.3.dist-info}/licenses/LICENSE +21 -21
- hud/agents/art.py +0 -101
- hud_python-0.4.1.dist-info/RECORD +0 -132
- {hud_python-0.4.1.dist-info ā hud_python-0.4.3.dist-info}/WHEEL +0 -0
- {hud_python-0.4.1.dist-info ā hud_python-0.4.3.dist-info}/entry_points.txt +0 -0
hud/cli/remote_runner.py
CHANGED
|
@@ -1,311 +1,311 @@
|
|
|
1
|
-
"""Remote runner for HUD MCP servers."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import asyncio
|
|
6
|
-
import os
|
|
7
|
-
import sys
|
|
8
|
-
|
|
9
|
-
import click
|
|
10
|
-
from fastmcp import FastMCP
|
|
11
|
-
|
|
12
|
-
from hud.settings import settings
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def parse_headers(header_args: list[str]) -> dict[str, str]:
|
|
16
|
-
"""Parse header arguments into a dictionary.
|
|
17
|
-
|
|
18
|
-
Args:
|
|
19
|
-
header_args: List of header strings in format "Key:Value" or "Key=Value"
|
|
20
|
-
|
|
21
|
-
Returns:
|
|
22
|
-
Dictionary of headers
|
|
23
|
-
"""
|
|
24
|
-
headers = {}
|
|
25
|
-
for header in header_args:
|
|
26
|
-
if ":" in header:
|
|
27
|
-
key, value = header.split(":", 1)
|
|
28
|
-
elif "=" in header:
|
|
29
|
-
key, value = header.split("=", 1)
|
|
30
|
-
else:
|
|
31
|
-
click.echo(f"ā ļø Invalid header format: {header} (use Key:Value or Key=Value)")
|
|
32
|
-
continue
|
|
33
|
-
|
|
34
|
-
headers[key.strip()] = value.strip()
|
|
35
|
-
|
|
36
|
-
return headers
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def parse_env_vars(env_args: list[str]) -> dict[str, str]:
|
|
40
|
-
"""Parse environment variable arguments into headers.
|
|
41
|
-
|
|
42
|
-
Args:
|
|
43
|
-
env_args: List of env var strings in format "KEY=VALUE"
|
|
44
|
-
|
|
45
|
-
Returns:
|
|
46
|
-
Dictionary of headers with Env- prefix
|
|
47
|
-
"""
|
|
48
|
-
env_headers = {}
|
|
49
|
-
for env in env_args:
|
|
50
|
-
if "=" not in env:
|
|
51
|
-
click.echo(f"ā ļø Invalid env format: {env} (use KEY=VALUE)")
|
|
52
|
-
continue
|
|
53
|
-
|
|
54
|
-
key, value = env.split("=", 1)
|
|
55
|
-
# Convert KEY_NAME to Env-Key-Name header format
|
|
56
|
-
# e.g., API_KEY=xxx becomes Env-Api-Key: xxx
|
|
57
|
-
# e.g., OPENAI_API_KEY=xxx becomes Env-Openai-Api-Key: xxx
|
|
58
|
-
header_parts = key.split("_")
|
|
59
|
-
header_key = f"Env-{'-'.join(part.capitalize() for part in header_parts)}"
|
|
60
|
-
env_headers[header_key] = value
|
|
61
|
-
|
|
62
|
-
return env_headers
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def build_remote_headers(
|
|
66
|
-
image: str,
|
|
67
|
-
env_args: list[str],
|
|
68
|
-
header_args: list[str],
|
|
69
|
-
api_key: str | None = None,
|
|
70
|
-
run_id: str | None = None,
|
|
71
|
-
) -> dict[str, str]:
|
|
72
|
-
"""Build headers for remote MCP server.
|
|
73
|
-
|
|
74
|
-
Args:
|
|
75
|
-
image: Docker image name
|
|
76
|
-
env_args: Environment variable arguments
|
|
77
|
-
header_args: Additional header arguments
|
|
78
|
-
api_key: API key (from env or arg)
|
|
79
|
-
run_id: Run ID (optional)
|
|
80
|
-
|
|
81
|
-
Returns:
|
|
82
|
-
Complete headers dictionary
|
|
83
|
-
"""
|
|
84
|
-
headers = {}
|
|
85
|
-
|
|
86
|
-
# Required headers
|
|
87
|
-
headers["Mcp-Image"] = image
|
|
88
|
-
|
|
89
|
-
# API key
|
|
90
|
-
if api_key:
|
|
91
|
-
headers["Authorization"] = f"Bearer {api_key}"
|
|
92
|
-
|
|
93
|
-
# Run ID if provided
|
|
94
|
-
if run_id:
|
|
95
|
-
headers["Run-Id"] = run_id
|
|
96
|
-
|
|
97
|
-
# Environment variables as headers
|
|
98
|
-
env_headers = parse_env_vars(env_args)
|
|
99
|
-
headers.update(env_headers)
|
|
100
|
-
|
|
101
|
-
# Additional headers
|
|
102
|
-
extra_headers = parse_headers(header_args)
|
|
103
|
-
headers.update(extra_headers)
|
|
104
|
-
|
|
105
|
-
return headers
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def run_remote_stdio(
|
|
109
|
-
url: str,
|
|
110
|
-
headers: dict[str, str],
|
|
111
|
-
verbose: bool = False,
|
|
112
|
-
) -> None:
|
|
113
|
-
"""Run remote MCP server with stdio transport."""
|
|
114
|
-
# CRITICAL: Configure ALL output to go to stderr to keep stdout clean for MCP protocol
|
|
115
|
-
import logging
|
|
116
|
-
import warnings
|
|
117
|
-
|
|
118
|
-
# Force all output to stderr
|
|
119
|
-
sys.stdout = sys.stderr
|
|
120
|
-
|
|
121
|
-
# Always disable FastMCP banner for stdio
|
|
122
|
-
os.environ["FASTMCP_DISABLE_BANNER"] = "1"
|
|
123
|
-
|
|
124
|
-
# Configure root logger to use stderr
|
|
125
|
-
root_logger = logging.getLogger()
|
|
126
|
-
root_logger.handlers.clear()
|
|
127
|
-
|
|
128
|
-
if not verbose:
|
|
129
|
-
# Suppress all logs and warnings for clean stdio
|
|
130
|
-
stderr_handler = logging.StreamHandler(sys.stderr)
|
|
131
|
-
stderr_handler.setLevel(logging.CRITICAL)
|
|
132
|
-
root_logger.addHandler(stderr_handler)
|
|
133
|
-
root_logger.setLevel(logging.CRITICAL)
|
|
134
|
-
|
|
135
|
-
# Set all known loggers to CRITICAL
|
|
136
|
-
for logger_name in ["fastmcp", "mcp", "httpx", "httpcore", "anyio", "asyncio", "uvicorn"]:
|
|
137
|
-
logging.getLogger(logger_name).setLevel(logging.CRITICAL)
|
|
138
|
-
|
|
139
|
-
# Suppress warnings
|
|
140
|
-
warnings.filterwarnings("ignore")
|
|
141
|
-
else:
|
|
142
|
-
# Only show important logs to stderr
|
|
143
|
-
stderr_handler = logging.StreamHandler(sys.stderr)
|
|
144
|
-
stderr_handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
|
|
145
|
-
root_logger.addHandler(stderr_handler)
|
|
146
|
-
root_logger.setLevel(logging.INFO)
|
|
147
|
-
|
|
148
|
-
async def run() -> None:
|
|
149
|
-
# Save the real stdout before we redirected it
|
|
150
|
-
real_stdout = sys.__stdout__
|
|
151
|
-
|
|
152
|
-
if verbose:
|
|
153
|
-
click.echo(f"š Connecting to: {url}", err=True)
|
|
154
|
-
click.echo(f"š¦ Image: {headers.get('Mcp-Image', 'unknown')}", err=True)
|
|
155
|
-
click.echo(f"š Headers: {list(headers.keys())}", err=True)
|
|
156
|
-
|
|
157
|
-
# Create proxy configuration
|
|
158
|
-
proxy_config = {
|
|
159
|
-
"mcpServers": {
|
|
160
|
-
"remote": {"transport": "streamable-http", "url": url, "headers": headers}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
try:
|
|
165
|
-
# Restore stdout for the proxy to use
|
|
166
|
-
sys.stdout = real_stdout
|
|
167
|
-
|
|
168
|
-
# Create proxy that forwards remote HTTP to local stdio
|
|
169
|
-
proxy = FastMCP.as_proxy(proxy_config, name="HUD Remote Proxy")
|
|
170
|
-
|
|
171
|
-
# Run with stdio transport - this will handle stdin/stdout properly
|
|
172
|
-
await proxy.run_async(transport="stdio", show_banner=False)
|
|
173
|
-
except Exception as e:
|
|
174
|
-
# Ensure errors go to stderr
|
|
175
|
-
sys.stdout = sys.stderr
|
|
176
|
-
if verbose:
|
|
177
|
-
import traceback
|
|
178
|
-
|
|
179
|
-
click.echo(f"ā Proxy error: {e}", err=True)
|
|
180
|
-
click.echo(traceback.format_exc(), err=True)
|
|
181
|
-
raise
|
|
182
|
-
|
|
183
|
-
try:
|
|
184
|
-
asyncio.run(run())
|
|
185
|
-
except KeyboardInterrupt:
|
|
186
|
-
if verbose:
|
|
187
|
-
click.echo("\nā
Remote proxy stopped", err=True)
|
|
188
|
-
sys.exit(0)
|
|
189
|
-
except Exception as e:
|
|
190
|
-
if verbose:
|
|
191
|
-
click.echo(f"ā Error: {e}", err=True)
|
|
192
|
-
sys.exit(1)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
async def run_remote_http(
|
|
196
|
-
url: str,
|
|
197
|
-
headers: dict[str, str],
|
|
198
|
-
port: int,
|
|
199
|
-
verbose: bool = False,
|
|
200
|
-
) -> None:
|
|
201
|
-
"""Run remote MCP server with HTTP transport."""
|
|
202
|
-
from .utils import find_free_port
|
|
203
|
-
|
|
204
|
-
# Find available port
|
|
205
|
-
actual_port = find_free_port(port)
|
|
206
|
-
if actual_port is None:
|
|
207
|
-
click.echo(f"ā No available ports found starting from {port}")
|
|
208
|
-
return
|
|
209
|
-
|
|
210
|
-
if actual_port != port:
|
|
211
|
-
click.echo(f"ā ļø Port {port} in use, using port {actual_port} instead")
|
|
212
|
-
|
|
213
|
-
# Suppress logs unless verbose
|
|
214
|
-
if not verbose:
|
|
215
|
-
import logging
|
|
216
|
-
import os
|
|
217
|
-
|
|
218
|
-
os.environ["FASTMCP_DISABLE_BANNER"] = "1"
|
|
219
|
-
logging.getLogger("fastmcp").setLevel(logging.ERROR)
|
|
220
|
-
logging.getLogger("mcp").setLevel(logging.ERROR)
|
|
221
|
-
logging.getLogger("uvicorn").setLevel(logging.ERROR)
|
|
222
|
-
logging.getLogger("uvicorn.access").setLevel(logging.ERROR)
|
|
223
|
-
logging.getLogger("uvicorn.error").setLevel(logging.ERROR)
|
|
224
|
-
logging.getLogger("httpx").setLevel(logging.ERROR)
|
|
225
|
-
logging.getLogger("httpcore").setLevel(logging.ERROR)
|
|
226
|
-
|
|
227
|
-
# Create the MCP config for the proxy
|
|
228
|
-
config = {"remote": {"transport": "streamable-http", "url": url, "headers": headers}}
|
|
229
|
-
|
|
230
|
-
# Create proxy that forwards remote HTTP to local HTTP
|
|
231
|
-
proxy = FastMCP.as_proxy(config, name="HUD Remote Proxy")
|
|
232
|
-
|
|
233
|
-
click.echo(f"š Starting HTTP proxy on port {actual_port}")
|
|
234
|
-
click.echo(f"š Server URL: http://localhost:{actual_port}/mcp")
|
|
235
|
-
click.echo(f"āļø Proxying to: {url}")
|
|
236
|
-
click.echo("ā¹ļø Press Ctrl+C to stop")
|
|
237
|
-
|
|
238
|
-
try:
|
|
239
|
-
# Run with HTTP transport
|
|
240
|
-
await proxy.run_async(
|
|
241
|
-
transport="http",
|
|
242
|
-
host="0.0.0.0", # noqa: S104
|
|
243
|
-
port=actual_port,
|
|
244
|
-
path="/mcp",
|
|
245
|
-
log_level="error" if not verbose else "info",
|
|
246
|
-
show_banner=False,
|
|
247
|
-
)
|
|
248
|
-
except KeyboardInterrupt:
|
|
249
|
-
click.echo("\nš Shutting down...")
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def run_remote_server(
|
|
253
|
-
image: str,
|
|
254
|
-
docker_args: list[str],
|
|
255
|
-
transport: str,
|
|
256
|
-
port: int,
|
|
257
|
-
url: str,
|
|
258
|
-
api_key: str | None,
|
|
259
|
-
run_id: str | None,
|
|
260
|
-
verbose: bool,
|
|
261
|
-
) -> None:
|
|
262
|
-
"""Run remote MCP server via proxy.
|
|
263
|
-
|
|
264
|
-
Args:
|
|
265
|
-
image: Docker image name
|
|
266
|
-
docker_args: Docker-style arguments (-e, -h)
|
|
267
|
-
transport: Output transport (stdio or http)
|
|
268
|
-
port: Port for HTTP transport
|
|
269
|
-
url: Remote MCP server URL
|
|
270
|
-
api_key: API key for authentication
|
|
271
|
-
run_id: Optional run ID
|
|
272
|
-
verbose: Show detailed logs
|
|
273
|
-
"""
|
|
274
|
-
# Parse docker args into env vars and headers
|
|
275
|
-
env_args = []
|
|
276
|
-
header_args = []
|
|
277
|
-
|
|
278
|
-
i = 0
|
|
279
|
-
while i < len(docker_args):
|
|
280
|
-
arg = docker_args[i]
|
|
281
|
-
|
|
282
|
-
if arg == "-e" and i + 1 < len(docker_args):
|
|
283
|
-
env_args.append(docker_args[i + 1])
|
|
284
|
-
i += 2
|
|
285
|
-
elif arg == "-h" and i + 1 < len(docker_args):
|
|
286
|
-
header_args.append(docker_args[i + 1])
|
|
287
|
-
i += 2
|
|
288
|
-
else:
|
|
289
|
-
click.echo(f"ā ļø Unknown argument: {arg}", err=True)
|
|
290
|
-
i += 1
|
|
291
|
-
|
|
292
|
-
# Get API key from env if not provided
|
|
293
|
-
if not api_key:
|
|
294
|
-
api_key = settings.api_key
|
|
295
|
-
if not api_key:
|
|
296
|
-
click.echo("ā API key required. Set HUD_API_KEY env var or use --api-key", err=True)
|
|
297
|
-
sys.exit(1)
|
|
298
|
-
|
|
299
|
-
# Build headers
|
|
300
|
-
headers = build_remote_headers(image, env_args, header_args, api_key, run_id)
|
|
301
|
-
|
|
302
|
-
if verbose:
|
|
303
|
-
click.echo(f"š§ Remote URL: {url}", err=True)
|
|
304
|
-
click.echo(f"š¦ Image: {image}", err=True)
|
|
305
|
-
click.echo(f"š Headers: {list(headers.keys())}", err=True)
|
|
306
|
-
|
|
307
|
-
# Run based on transport
|
|
308
|
-
if transport == "stdio":
|
|
309
|
-
run_remote_stdio(url, headers, verbose)
|
|
310
|
-
else:
|
|
311
|
-
asyncio.run(run_remote_http(url, headers, port, verbose))
|
|
1
|
+
"""Remote runner for HUD MCP servers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from fastmcp import FastMCP
|
|
11
|
+
|
|
12
|
+
from hud.settings import settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_headers(header_args: list[str]) -> dict[str, str]:
|
|
16
|
+
"""Parse header arguments into a dictionary.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
header_args: List of header strings in format "Key:Value" or "Key=Value"
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Dictionary of headers
|
|
23
|
+
"""
|
|
24
|
+
headers = {}
|
|
25
|
+
for header in header_args:
|
|
26
|
+
if ":" in header:
|
|
27
|
+
key, value = header.split(":", 1)
|
|
28
|
+
elif "=" in header:
|
|
29
|
+
key, value = header.split("=", 1)
|
|
30
|
+
else:
|
|
31
|
+
click.echo(f"ā ļø Invalid header format: {header} (use Key:Value or Key=Value)")
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
headers[key.strip()] = value.strip()
|
|
35
|
+
|
|
36
|
+
return headers
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_env_vars(env_args: list[str]) -> dict[str, str]:
|
|
40
|
+
"""Parse environment variable arguments into headers.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
env_args: List of env var strings in format "KEY=VALUE"
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Dictionary of headers with Env- prefix
|
|
47
|
+
"""
|
|
48
|
+
env_headers = {}
|
|
49
|
+
for env in env_args:
|
|
50
|
+
if "=" not in env:
|
|
51
|
+
click.echo(f"ā ļø Invalid env format: {env} (use KEY=VALUE)")
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
key, value = env.split("=", 1)
|
|
55
|
+
# Convert KEY_NAME to Env-Key-Name header format
|
|
56
|
+
# e.g., API_KEY=xxx becomes Env-Api-Key: xxx
|
|
57
|
+
# e.g., OPENAI_API_KEY=xxx becomes Env-Openai-Api-Key: xxx
|
|
58
|
+
header_parts = key.split("_")
|
|
59
|
+
header_key = f"Env-{'-'.join(part.capitalize() for part in header_parts)}"
|
|
60
|
+
env_headers[header_key] = value
|
|
61
|
+
|
|
62
|
+
return env_headers
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_remote_headers(
|
|
66
|
+
image: str,
|
|
67
|
+
env_args: list[str],
|
|
68
|
+
header_args: list[str],
|
|
69
|
+
api_key: str | None = None,
|
|
70
|
+
run_id: str | None = None,
|
|
71
|
+
) -> dict[str, str]:
|
|
72
|
+
"""Build headers for remote MCP server.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
image: Docker image name
|
|
76
|
+
env_args: Environment variable arguments
|
|
77
|
+
header_args: Additional header arguments
|
|
78
|
+
api_key: API key (from env or arg)
|
|
79
|
+
run_id: Run ID (optional)
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Complete headers dictionary
|
|
83
|
+
"""
|
|
84
|
+
headers = {}
|
|
85
|
+
|
|
86
|
+
# Required headers
|
|
87
|
+
headers["Mcp-Image"] = image
|
|
88
|
+
|
|
89
|
+
# API key
|
|
90
|
+
if api_key:
|
|
91
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
92
|
+
|
|
93
|
+
# Run ID if provided
|
|
94
|
+
if run_id:
|
|
95
|
+
headers["Run-Id"] = run_id
|
|
96
|
+
|
|
97
|
+
# Environment variables as headers
|
|
98
|
+
env_headers = parse_env_vars(env_args)
|
|
99
|
+
headers.update(env_headers)
|
|
100
|
+
|
|
101
|
+
# Additional headers
|
|
102
|
+
extra_headers = parse_headers(header_args)
|
|
103
|
+
headers.update(extra_headers)
|
|
104
|
+
|
|
105
|
+
return headers
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run_remote_stdio(
|
|
109
|
+
url: str,
|
|
110
|
+
headers: dict[str, str],
|
|
111
|
+
verbose: bool = False,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Run remote MCP server with stdio transport."""
|
|
114
|
+
# CRITICAL: Configure ALL output to go to stderr to keep stdout clean for MCP protocol
|
|
115
|
+
import logging
|
|
116
|
+
import warnings
|
|
117
|
+
|
|
118
|
+
# Force all output to stderr
|
|
119
|
+
sys.stdout = sys.stderr
|
|
120
|
+
|
|
121
|
+
# Always disable FastMCP banner for stdio
|
|
122
|
+
os.environ["FASTMCP_DISABLE_BANNER"] = "1"
|
|
123
|
+
|
|
124
|
+
# Configure root logger to use stderr
|
|
125
|
+
root_logger = logging.getLogger()
|
|
126
|
+
root_logger.handlers.clear()
|
|
127
|
+
|
|
128
|
+
if not verbose:
|
|
129
|
+
# Suppress all logs and warnings for clean stdio
|
|
130
|
+
stderr_handler = logging.StreamHandler(sys.stderr)
|
|
131
|
+
stderr_handler.setLevel(logging.CRITICAL)
|
|
132
|
+
root_logger.addHandler(stderr_handler)
|
|
133
|
+
root_logger.setLevel(logging.CRITICAL)
|
|
134
|
+
|
|
135
|
+
# Set all known loggers to CRITICAL
|
|
136
|
+
for logger_name in ["fastmcp", "mcp", "httpx", "httpcore", "anyio", "asyncio", "uvicorn"]:
|
|
137
|
+
logging.getLogger(logger_name).setLevel(logging.CRITICAL)
|
|
138
|
+
|
|
139
|
+
# Suppress warnings
|
|
140
|
+
warnings.filterwarnings("ignore")
|
|
141
|
+
else:
|
|
142
|
+
# Only show important logs to stderr
|
|
143
|
+
stderr_handler = logging.StreamHandler(sys.stderr)
|
|
144
|
+
stderr_handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
|
|
145
|
+
root_logger.addHandler(stderr_handler)
|
|
146
|
+
root_logger.setLevel(logging.INFO)
|
|
147
|
+
|
|
148
|
+
async def run() -> None:
|
|
149
|
+
# Save the real stdout before we redirected it
|
|
150
|
+
real_stdout = sys.__stdout__
|
|
151
|
+
|
|
152
|
+
if verbose:
|
|
153
|
+
click.echo(f"š Connecting to: {url}", err=True)
|
|
154
|
+
click.echo(f"š¦ Image: {headers.get('Mcp-Image', 'unknown')}", err=True)
|
|
155
|
+
click.echo(f"š Headers: {list(headers.keys())}", err=True)
|
|
156
|
+
|
|
157
|
+
# Create proxy configuration
|
|
158
|
+
proxy_config = {
|
|
159
|
+
"mcpServers": {
|
|
160
|
+
"remote": {"transport": "streamable-http", "url": url, "headers": headers}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
# Restore stdout for the proxy to use
|
|
166
|
+
sys.stdout = real_stdout
|
|
167
|
+
|
|
168
|
+
# Create proxy that forwards remote HTTP to local stdio
|
|
169
|
+
proxy = FastMCP.as_proxy(proxy_config, name="HUD Remote Proxy")
|
|
170
|
+
|
|
171
|
+
# Run with stdio transport - this will handle stdin/stdout properly
|
|
172
|
+
await proxy.run_async(transport="stdio", show_banner=False)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
# Ensure errors go to stderr
|
|
175
|
+
sys.stdout = sys.stderr
|
|
176
|
+
if verbose:
|
|
177
|
+
import traceback
|
|
178
|
+
|
|
179
|
+
click.echo(f"ā Proxy error: {e}", err=True)
|
|
180
|
+
click.echo(traceback.format_exc(), err=True)
|
|
181
|
+
raise
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
asyncio.run(run())
|
|
185
|
+
except KeyboardInterrupt:
|
|
186
|
+
if verbose:
|
|
187
|
+
click.echo("\nā
Remote proxy stopped", err=True)
|
|
188
|
+
sys.exit(0)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
if verbose:
|
|
191
|
+
click.echo(f"ā Error: {e}", err=True)
|
|
192
|
+
sys.exit(1)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def run_remote_http(
|
|
196
|
+
url: str,
|
|
197
|
+
headers: dict[str, str],
|
|
198
|
+
port: int,
|
|
199
|
+
verbose: bool = False,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Run remote MCP server with HTTP transport."""
|
|
202
|
+
from .utils import find_free_port
|
|
203
|
+
|
|
204
|
+
# Find available port
|
|
205
|
+
actual_port = find_free_port(port)
|
|
206
|
+
if actual_port is None:
|
|
207
|
+
click.echo(f"ā No available ports found starting from {port}")
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
if actual_port != port:
|
|
211
|
+
click.echo(f"ā ļø Port {port} in use, using port {actual_port} instead")
|
|
212
|
+
|
|
213
|
+
# Suppress logs unless verbose
|
|
214
|
+
if not verbose:
|
|
215
|
+
import logging
|
|
216
|
+
import os
|
|
217
|
+
|
|
218
|
+
os.environ["FASTMCP_DISABLE_BANNER"] = "1"
|
|
219
|
+
logging.getLogger("fastmcp").setLevel(logging.ERROR)
|
|
220
|
+
logging.getLogger("mcp").setLevel(logging.ERROR)
|
|
221
|
+
logging.getLogger("uvicorn").setLevel(logging.ERROR)
|
|
222
|
+
logging.getLogger("uvicorn.access").setLevel(logging.ERROR)
|
|
223
|
+
logging.getLogger("uvicorn.error").setLevel(logging.ERROR)
|
|
224
|
+
logging.getLogger("httpx").setLevel(logging.ERROR)
|
|
225
|
+
logging.getLogger("httpcore").setLevel(logging.ERROR)
|
|
226
|
+
|
|
227
|
+
# Create the MCP config for the proxy
|
|
228
|
+
config = {"remote": {"transport": "streamable-http", "url": url, "headers": headers}}
|
|
229
|
+
|
|
230
|
+
# Create proxy that forwards remote HTTP to local HTTP
|
|
231
|
+
proxy = FastMCP.as_proxy(config, name="HUD Remote Proxy")
|
|
232
|
+
|
|
233
|
+
click.echo(f"š Starting HTTP proxy on port {actual_port}")
|
|
234
|
+
click.echo(f"š Server URL: http://localhost:{actual_port}/mcp")
|
|
235
|
+
click.echo(f"āļø Proxying to: {url}")
|
|
236
|
+
click.echo("ā¹ļø Press Ctrl+C to stop")
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
# Run with HTTP transport
|
|
240
|
+
await proxy.run_async(
|
|
241
|
+
transport="http",
|
|
242
|
+
host="0.0.0.0", # noqa: S104
|
|
243
|
+
port=actual_port,
|
|
244
|
+
path="/mcp",
|
|
245
|
+
log_level="error" if not verbose else "info",
|
|
246
|
+
show_banner=False,
|
|
247
|
+
)
|
|
248
|
+
except KeyboardInterrupt:
|
|
249
|
+
click.echo("\nš Shutting down...")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def run_remote_server(
|
|
253
|
+
image: str,
|
|
254
|
+
docker_args: list[str],
|
|
255
|
+
transport: str,
|
|
256
|
+
port: int,
|
|
257
|
+
url: str,
|
|
258
|
+
api_key: str | None,
|
|
259
|
+
run_id: str | None,
|
|
260
|
+
verbose: bool,
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Run remote MCP server via proxy.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
image: Docker image name
|
|
266
|
+
docker_args: Docker-style arguments (-e, -h)
|
|
267
|
+
transport: Output transport (stdio or http)
|
|
268
|
+
port: Port for HTTP transport
|
|
269
|
+
url: Remote MCP server URL
|
|
270
|
+
api_key: API key for authentication
|
|
271
|
+
run_id: Optional run ID
|
|
272
|
+
verbose: Show detailed logs
|
|
273
|
+
"""
|
|
274
|
+
# Parse docker args into env vars and headers
|
|
275
|
+
env_args = []
|
|
276
|
+
header_args = []
|
|
277
|
+
|
|
278
|
+
i = 0
|
|
279
|
+
while i < len(docker_args):
|
|
280
|
+
arg = docker_args[i]
|
|
281
|
+
|
|
282
|
+
if arg == "-e" and i + 1 < len(docker_args):
|
|
283
|
+
env_args.append(docker_args[i + 1])
|
|
284
|
+
i += 2
|
|
285
|
+
elif arg == "-h" and i + 1 < len(docker_args):
|
|
286
|
+
header_args.append(docker_args[i + 1])
|
|
287
|
+
i += 2
|
|
288
|
+
else:
|
|
289
|
+
click.echo(f"ā ļø Unknown argument: {arg}", err=True)
|
|
290
|
+
i += 1
|
|
291
|
+
|
|
292
|
+
# Get API key from env if not provided
|
|
293
|
+
if not api_key:
|
|
294
|
+
api_key = settings.api_key
|
|
295
|
+
if not api_key:
|
|
296
|
+
click.echo("ā API key required. Set HUD_API_KEY env var or use --api-key", err=True)
|
|
297
|
+
sys.exit(1)
|
|
298
|
+
|
|
299
|
+
# Build headers
|
|
300
|
+
headers = build_remote_headers(image, env_args, header_args, api_key, run_id)
|
|
301
|
+
|
|
302
|
+
if verbose:
|
|
303
|
+
click.echo(f"š§ Remote URL: {url}", err=True)
|
|
304
|
+
click.echo(f"š¦ Image: {image}", err=True)
|
|
305
|
+
click.echo(f"š Headers: {list(headers.keys())}", err=True)
|
|
306
|
+
|
|
307
|
+
# Run based on transport
|
|
308
|
+
if transport == "stdio":
|
|
309
|
+
run_remote_stdio(url, headers, verbose)
|
|
310
|
+
else:
|
|
311
|
+
asyncio.run(run_remote_http(url, headers, port, verbose))
|