hud-python 0.4.36__py3-none-any.whl → 0.4.38__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.

Files changed (44) hide show
  1. hud/agents/__init__.py +2 -0
  2. hud/agents/lite_llm.py +72 -0
  3. hud/agents/openai_chat_generic.py +21 -7
  4. hud/cli/__init__.py +19 -4
  5. hud/cli/build.py +17 -2
  6. hud/cli/dev.py +1 -1
  7. hud/cli/eval.py +93 -13
  8. hud/cli/flows/tasks.py +197 -65
  9. hud/cli/init.py +1 -1
  10. hud/cli/push.py +9 -0
  11. hud/cli/rl/__init__.py +14 -4
  12. hud/cli/rl/celebrate.py +187 -0
  13. hud/cli/rl/config.py +15 -8
  14. hud/cli/rl/local_runner.py +44 -20
  15. hud/cli/rl/remote_runner.py +164 -87
  16. hud/cli/rl/viewer.py +141 -0
  17. hud/cli/rl/wait_utils.py +89 -0
  18. hud/cli/utils/env_check.py +196 -0
  19. hud/cli/utils/source_hash.py +108 -0
  20. hud/clients/base.py +1 -1
  21. hud/clients/fastmcp.py +1 -1
  22. hud/otel/config.py +1 -1
  23. hud/otel/context.py +2 -2
  24. hud/rl/vllm_adapter.py +1 -1
  25. hud/server/server.py +84 -13
  26. hud/server/tests/test_add_tool.py +60 -0
  27. hud/server/tests/test_context.py +128 -0
  28. hud/server/tests/test_mcp_server_handlers.py +44 -0
  29. hud/server/tests/test_mcp_server_integration.py +405 -0
  30. hud/server/tests/test_mcp_server_more.py +247 -0
  31. hud/server/tests/test_run_wrapper.py +53 -0
  32. hud/server/tests/test_server_extra.py +166 -0
  33. hud/server/tests/test_sigterm_runner.py +78 -0
  34. hud/shared/hints.py +1 -1
  35. hud/telemetry/job.py +2 -2
  36. hud/types.py +9 -2
  37. hud/utils/tasks.py +32 -24
  38. hud/utils/tests/test_version.py +1 -1
  39. hud/version.py +1 -1
  40. {hud_python-0.4.36.dist-info → hud_python-0.4.38.dist-info}/METADATA +14 -12
  41. {hud_python-0.4.36.dist-info → hud_python-0.4.38.dist-info}/RECORD +44 -30
  42. {hud_python-0.4.36.dist-info → hud_python-0.4.38.dist-info}/WHEEL +0 -0
  43. {hud_python-0.4.36.dist-info → hud_python-0.4.38.dist-info}/entry_points.txt +0 -0
  44. {hud_python-0.4.36.dist-info → hud_python-0.4.38.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import os
5
+ import select
6
+ import sys
7
+ import threading
8
+ import time as _time
9
+ from typing import TYPE_CHECKING
10
+
11
+ from watchfiles import watch
12
+
13
+ if TYPE_CHECKING:
14
+ from pathlib import Path
15
+
16
+
17
+ def wait_for_enter_cancel_or_change(file_path: Path) -> tuple[bool, bool, bool]:
18
+ """Block until Enter (start), 'q' (cancel), or file change.
19
+
20
+ Returns (start_training, cancelled, changed).
21
+ - start_training: True if Enter (or any non-'q' line on POSIX) was received
22
+ - cancelled: True if 'q' was received or Ctrl-C
23
+ - changed: True if the file changed on disk
24
+ """
25
+ start_training = False
26
+ cancelled = False
27
+ changed = False
28
+
29
+ stop_evt: threading.Event = threading.Event()
30
+ changed_evt: threading.Event = threading.Event()
31
+
32
+ def _watcher() -> None:
33
+ with contextlib.suppress(Exception):
34
+ for _ in watch(file_path, stop_event=stop_evt, debounce=200):
35
+ changed_evt.set()
36
+ break
37
+
38
+ t = threading.Thread(target=_watcher, daemon=True)
39
+ t.start()
40
+
41
+ try:
42
+ if os.name == "nt":
43
+ import msvcrt # type: ignore[attr-defined]
44
+
45
+ while True:
46
+ if changed_evt.is_set():
47
+ changed = True
48
+ break
49
+
50
+ if msvcrt.kbhit():
51
+ ch = msvcrt.getwch()
52
+ if ch in ("\r", "\n"):
53
+ start_training = True
54
+ break
55
+ if ch.lower() == "q":
56
+ cancelled = True
57
+ break
58
+ _time.sleep(0.15)
59
+ else:
60
+ while True:
61
+ if changed_evt.is_set():
62
+ changed = True
63
+ break
64
+
65
+ rlist, _, _ = select.select([sys.stdin], [], [], 0.25)
66
+ if rlist:
67
+ line = sys.stdin.readline()
68
+ if line is None:
69
+ continue
70
+ stripped = line.strip().lower()
71
+ if stripped == "q":
72
+ cancelled = True
73
+ break
74
+ # Any other (including empty) => start
75
+ start_training = True
76
+ break
77
+ _time.sleep(0.05)
78
+
79
+ except KeyboardInterrupt:
80
+ cancelled = True
81
+ finally:
82
+ stop_evt.set()
83
+ with contextlib.suppress(Exception):
84
+ t.join(timeout=1)
85
+
86
+ return start_training, cancelled, changed
87
+
88
+
89
+ __all__ = ["wait_for_enter_cancel_or_change"]
@@ -0,0 +1,196 @@
1
+ """Environment build checks and discovery helpers.
2
+
3
+ Shared utilities to:
4
+ - locate an environment directory related to a tasks file
5
+ - ensure the environment is built and up-to-date via source hash comparison
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import typer
16
+ import yaml
17
+
18
+ from hud.utils.hud_console import hud_console
19
+
20
+ from .docker import require_docker_running
21
+ from .source_hash import compute_source_hash, list_source_files
22
+
23
+
24
+ def _parse_generated_at(lock_data: dict[str, Any]) -> float | None:
25
+ """Parse build.generatedAt into a POSIX timestamp (seconds).
26
+
27
+ Returns None if missing or unparsable.
28
+ """
29
+ try:
30
+ generated_at = (lock_data.get("build") or {}).get("generatedAt")
31
+ if not isinstance(generated_at, str):
32
+ return None
33
+ # Support ...Z and offsets
34
+ iso = generated_at.replace("Z", "+00:00")
35
+ dt = datetime.fromisoformat(iso)
36
+ if dt.tzinfo is None:
37
+ dt = dt.replace(tzinfo=UTC)
38
+ return dt.timestamp()
39
+ except Exception:
40
+ return None
41
+
42
+
43
+ def _collect_source_diffs(env_dir: Path, lock_data: dict[str, Any]) -> dict[str, list[str]]:
44
+ """Compute added/removed/modified files since last build using names + mtimes.
45
+
46
+ - added/removed are based on the stored build.sourceFiles list vs current file list
47
+ - modified is based on mtime newer than build.generatedAt for files present now
48
+ """
49
+ try:
50
+ stored_files = (
51
+ (lock_data.get("build") or {}).get("sourceFiles") if isinstance(lock_data, dict) else []
52
+ )
53
+ stored_set = set(str(p) for p in (stored_files or []))
54
+ except Exception:
55
+ stored_set = set()
56
+
57
+ current_paths = list_source_files(env_dir)
58
+ # Normalize to POSIX-style relative strings
59
+ current_list = [str(p.resolve().relative_to(env_dir)).replace("\\", "/") for p in current_paths]
60
+ current_set = set(current_list)
61
+
62
+ added = sorted(current_set - stored_set)
63
+ removed = sorted(stored_set - current_set)
64
+
65
+ # Modified: mtime newer than build.generatedAt
66
+ modified: list[str] = []
67
+ built_ts = _parse_generated_at(lock_data)
68
+ if built_ts is not None:
69
+ for rel in sorted(current_set & (stored_set or current_set)):
70
+ with contextlib.suppress(Exception):
71
+ p = env_dir / rel
72
+ if p.exists() and p.stat().st_mtime > built_ts:
73
+ modified.append(rel)
74
+
75
+ return {"added": added, "removed": removed, "modified": modified}
76
+
77
+
78
+ def find_environment_dir(tasks_path: Path) -> Path | None:
79
+ """Best-effort discovery of a nearby environment directory.
80
+
81
+ Preference order:
82
+ - directory with hud.lock.yaml
83
+ - directory that looks like an environment (Dockerfile + pyproject.toml)
84
+ - searches tasks dir, CWD, and a couple of parents
85
+ """
86
+ from .environment import is_environment_directory # local import to avoid cycles
87
+
88
+ candidates: list[Path] = []
89
+ cwd = Path.cwd()
90
+ candidates.extend([tasks_path.parent, cwd])
91
+
92
+ # Add parents (up to 2 levels for each)
93
+ for base in list(candidates):
94
+ p = base
95
+ for _ in range(2):
96
+ p = p.parent
97
+ if p not in candidates:
98
+ candidates.append(p)
99
+
100
+ # Prefer those with hud.lock.yaml
101
+ for d in candidates:
102
+ if (d / "hud.lock.yaml").exists():
103
+ return d
104
+
105
+ # Otherwise, find a plausible environment dir
106
+ for d in candidates:
107
+ try:
108
+ if is_environment_directory(d):
109
+ return d
110
+ except Exception as e:
111
+ hud_console.debug(f"Skipping path {d}: {e}")
112
+ continue
113
+
114
+ return None
115
+
116
+
117
+ def ensure_built(env_dir: Path, *, interactive: bool = True) -> dict[str, Any]:
118
+ """Ensure env has a lock and matches current sources via source hash.
119
+
120
+ If interactive is True, prompts to build/rebuild as needed. If False, only warns.
121
+ Returns the loaded lock data (empty dict if unreadable/missing).
122
+ """
123
+ from hud.cli.build import build_environment # local import to avoid import cycles
124
+
125
+ lock_path = env_dir / "hud.lock.yaml"
126
+ if not lock_path.exists():
127
+ if interactive:
128
+ hud_console.warning("No hud.lock.yaml found. The environment hasn't been built.")
129
+ ok = hud_console.confirm("Build the environment now (runs 'hud build')?", default=True)
130
+ if not ok:
131
+ raise typer.Exit(1)
132
+ require_docker_running()
133
+ build_environment(str(env_dir), platform="linux/amd64")
134
+ else:
135
+ hud_console.dim_info(
136
+ "Info",
137
+ "No hud.lock.yaml found nearby; skipping environment change checks.",
138
+ )
139
+ return {}
140
+
141
+ # Load lock file
142
+ try:
143
+ with open(lock_path) as f:
144
+ lock_data: dict[str, Any] = yaml.safe_load(f) or {}
145
+ except Exception:
146
+ lock_data = {}
147
+
148
+ # Fast change detection: recompute source hash and compare
149
+ try:
150
+ current_hash = compute_source_hash(env_dir)
151
+ stored_hash = (
152
+ (lock_data.get("build") or {}).get("sourceHash")
153
+ if isinstance(lock_data, dict)
154
+ else None
155
+ )
156
+ if stored_hash and current_hash and stored_hash != current_hash:
157
+ hud_console.warning("Environment sources changed since last build.")
158
+
159
+ # Show a brief diff summary to help users understand changes
160
+ diffs = _collect_source_diffs(env_dir, lock_data)
161
+
162
+ def _print_section(name: str, items: list[str]) -> None:
163
+ if not items:
164
+ return
165
+ # Limit output to avoid flooding the console
166
+ preview = items[:20]
167
+ more = len(items) - len(preview)
168
+ hud_console.section_title(name)
169
+ for rel in preview:
170
+ hud_console.dim_info("", rel)
171
+ if more > 0:
172
+ hud_console.dim_info("", f"... and {more} more")
173
+
174
+ _print_section("Modified files", diffs.get("modified", []))
175
+ _print_section("Added files", diffs.get("added", []))
176
+ _print_section("Removed files", diffs.get("removed", []))
177
+
178
+ if interactive:
179
+ if hud_console.confirm("Rebuild now (runs 'hud build')?", default=True):
180
+ require_docker_running()
181
+ build_environment(str(env_dir), platform="linux/amd64")
182
+ with open(lock_path) as f:
183
+ lock_data = yaml.safe_load(f) or {}
184
+ else:
185
+ hud_console.hint("Continuing without rebuild; this may use an outdated image.")
186
+ else:
187
+ hud_console.hint("Run 'hud build' to update the image before proceeding.")
188
+ elif not stored_hash:
189
+ hud_console.dim_info(
190
+ "Info",
191
+ "No source hash in lock; rebuild to enable change checks.",
192
+ )
193
+ except Exception as e:
194
+ hud_console.debug(f"Source hash check skipped: {e}")
195
+
196
+ return lock_data
@@ -0,0 +1,108 @@
1
+ """Utilities to compute a fast, deterministic source hash for environments.
2
+
3
+ This intentionally focuses on the typical HUD environment layout and aims to be fast:
4
+ - Always include: Dockerfile, pyproject.toml
5
+ - Include directories: controller/, environment/, src/
6
+ - Exclude common build/runtime caches and lock files
7
+
8
+ Note: This is not a full Docker build context hash and does not parse .dockerignore.
9
+ It is sufficient to detect meaningful changes for HUD environments quickly.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import os
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Iterable
21
+
22
+ EXCLUDE_DIRS = {
23
+ ".git",
24
+ ".venv",
25
+ "dist",
26
+ "build",
27
+ "node_modules",
28
+ "__pycache__",
29
+ ".mypy_cache",
30
+ ".pytest_cache",
31
+ ".ruff_cache",
32
+ }
33
+
34
+ EXCLUDE_FILE_SUFFIXES = {
35
+ ".pyc",
36
+ ".log",
37
+ }
38
+
39
+ EXCLUDE_FILES = {
40
+ "hud.lock.yaml",
41
+ }
42
+
43
+ INCLUDE_FILES = {"Dockerfile", "pyproject.toml"}
44
+ INCLUDE_DIRS = {"controller", "environment"}
45
+
46
+
47
+ def iter_source_files(root: Path) -> Iterable[Path]:
48
+ """Yield files to include in the source hash.
49
+
50
+ The order is not guaranteed; callers should sort for deterministic hashing.
51
+ """
52
+ # Always include top-level files if present
53
+ for name in INCLUDE_FILES:
54
+ p = root / name
55
+ if p.is_file():
56
+ yield p
57
+
58
+ # Include known directories
59
+ for d in INCLUDE_DIRS:
60
+ dp = root / d
61
+ if not dp.exists():
62
+ continue
63
+ for dirpath, dirnames, filenames in os.walk(dp):
64
+ # prune excluded dirs in-place
65
+ dirnames[:] = [dn for dn in dirnames if dn not in EXCLUDE_DIRS]
66
+ for fn in filenames:
67
+ if fn in EXCLUDE_FILES:
68
+ continue
69
+ if any(fn.endswith(suf) for suf in EXCLUDE_FILE_SUFFIXES):
70
+ continue
71
+ yield Path(dirpath) / fn
72
+
73
+
74
+ def list_source_files(root: Path) -> list[Path]:
75
+ """Return a sorted list of files used for the source hash.
76
+
77
+ Sorting is by relative path to ensure deterministic ordering.
78
+ """
79
+ root = root.resolve()
80
+ files = list(iter_source_files(root))
81
+ files.sort(key=lambda p: str(p.resolve().relative_to(root)).replace("\\", "/"))
82
+ return files
83
+
84
+
85
+ def compute_source_hash(directory: str | Path) -> str:
86
+ """Compute a deterministic SHA-256 hash over relevant source files.
87
+
88
+ Args:
89
+ directory: Environment directory root.
90
+
91
+ Returns:
92
+ Hex digest string.
93
+ """
94
+ root = Path(directory).resolve()
95
+ files = list_source_files(root)
96
+
97
+ hasher = hashlib.sha256()
98
+ for p in files:
99
+ rel = str(p.resolve().relative_to(root)).replace("\\", "/")
100
+ hasher.update(rel.encode("utf-8"))
101
+ with open(p, "rb") as f:
102
+ while True:
103
+ chunk = f.read(8192)
104
+ if not chunk:
105
+ break
106
+ hasher.update(chunk)
107
+
108
+ return hasher.hexdigest()
hud/clients/base.py CHANGED
@@ -139,7 +139,7 @@ class BaseHUDClient(AgentMCPClient):
139
139
  raise HudAuthenticationError(
140
140
  f'Sending authorization "{headers.get("Authorization", "")}", which may'
141
141
  " be incomplete. Ensure HUD_API_KEY environment variable is set or send it"
142
- " as a header. You can get an API key at https://app.hud.so"
142
+ " as a header. You can get an API key at https://hud.so"
143
143
  )
144
144
  # Subclasses implement connection
145
145
  await self._connect(self._mcp_config)
hud/clients/fastmcp.py CHANGED
@@ -95,7 +95,7 @@ class FastMCPHUDClient(BaseHUDClient):
95
95
  raise RuntimeError(
96
96
  "Authentication failed for HUD API. "
97
97
  "Please ensure your HUD_API_KEY environment variable is set correctly." # noqa: E501
98
- "You can get an API key at https://app.hud.so"
98
+ "You can get an API key at https://hud.so"
99
99
  ) from e
100
100
  # Generic 401 error
101
101
  raise RuntimeError(
hud/otel/config.py CHANGED
@@ -111,7 +111,7 @@ def configure_telemetry(
111
111
  # Error if no exporters are configured
112
112
  raise ValueError(
113
113
  "No telemetry backend configured. Either:\n"
114
- "1. Set HUD_API_KEY environment variable for HUD telemetry (https://app.hud.so)\n"
114
+ "1. Set HUD_API_KEY environment variable for HUD telemetry (https://hud.so)\n"
115
115
  "2. Use enable_otlp=True with configure_telemetry() for alternative backends (e.g., Jaeger)\n" # noqa: E501
116
116
  )
117
117
  elif not settings.telemetry_enabled:
hud/otel/context.py CHANGED
@@ -376,7 +376,7 @@ def _print_trace_url(task_run_id: str) -> None:
376
376
  if not (settings.telemetry_enabled and settings.api_key):
377
377
  return
378
378
 
379
- url = f"https://app.hud.so/trace/{task_run_id}"
379
+ url = f"https://hud.so/trace/{task_run_id}"
380
380
  header = "🚀 See your agent live at:"
381
381
 
382
382
  # ANSI color codes
@@ -415,7 +415,7 @@ def _print_trace_complete_url(task_run_id: str, error_occurred: bool = False) ->
415
415
  if not (settings.telemetry_enabled and settings.api_key):
416
416
  return
417
417
 
418
- url = f"https://app.hud.so/trace/{task_run_id}"
418
+ url = f"https://hud.so/trace/{task_run_id}"
419
419
 
420
420
  # ANSI color codes
421
421
  GREEN = "\033[92m"
hud/rl/vllm_adapter.py CHANGED
@@ -36,7 +36,7 @@ class VLLMAdapter:
36
36
  headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
37
37
  payload = {"lora_name": adapter_name, "lora_path": adapter_path}
38
38
  # Implement exponential backoff for retrying the adapter load request.
39
- max_retries = 5
39
+ max_retries = 8
40
40
  backoff_factor = 2
41
41
  delay = 1 # initial delay in seconds
42
42
 
hud/server/server.py CHANGED
@@ -20,7 +20,6 @@ from hud.server.low_level import LowLevelServerWithInit
20
20
  if TYPE_CHECKING:
21
21
  from collections.abc import AsyncGenerator, Callable
22
22
 
23
- from mcp.shared.context import RequestContext
24
23
  from starlette.requests import Request
25
24
 
26
25
  __all__ = ["MCPServer"]
@@ -37,6 +36,31 @@ def _run_with_sigterm(coro_fn: Callable[..., Any], *args: Any, **kwargs: Any) ->
37
36
 
38
37
  sys.stderr.flush()
39
38
 
39
+ # Check if we're already in an event loop
40
+ try:
41
+ loop = asyncio.get_running_loop()
42
+ logger.warning(
43
+ "HUD server is running in an existing event loop. "
44
+ "SIGTERM handling may be limited. "
45
+ "Consider using await hub.run_async() instead of hub.run() in async contexts."
46
+ )
47
+
48
+ task = loop.create_task(coro_fn(*args, **kwargs))
49
+
50
+ # Try to handle SIGTERM if possible
51
+ if sys.platform != "win32":
52
+
53
+ def handle_sigterm(signum: Any, frame: Any) -> None:
54
+ logger.info("SIGTERM received in async context, cancelling task...")
55
+ loop.call_soon_threadsafe(task.cancel)
56
+
57
+ signal.signal(signal.SIGTERM, handle_sigterm)
58
+
59
+ return
60
+
61
+ except RuntimeError:
62
+ pass
63
+
40
64
  async def _runner() -> None:
41
65
  stop_evt: asyncio.Event | None = None
42
66
  if sys.platform != "win32" and os.getenv("FASTMCP_DISABLE_SIGTERM_HANDLER") != "1":
@@ -127,7 +151,11 @@ class MCPServer(FastMCP):
127
151
  # Force flush logs to ensure they're visible
128
152
  sys.stderr.flush()
129
153
 
130
- if self._shutdown_fn is not None and _sigterm_received:
154
+ if (
155
+ self._shutdown_fn is not None
156
+ and _sigterm_received
157
+ and not self._shutdown_has_run
158
+ ):
131
159
  logger.info("SIGTERM detected! Calling @mcp.shutdown handler...")
132
160
  sys.stderr.flush()
133
161
  try:
@@ -137,7 +165,9 @@ class MCPServer(FastMCP):
137
165
  except Exception as e:
138
166
  logger.error("Error during @mcp.shutdown: %s", e)
139
167
  sys.stderr.flush()
140
- _sigterm_received = False
168
+ finally:
169
+ self._shutdown_has_run = True
170
+ _sigterm_received = False
141
171
  elif self._shutdown_fn is not None:
142
172
  logger.info(
143
173
  "No SIGTERM. This is a hot reload (SIGINT) or normal exit. Skipping @mcp.shutdown handler." # noqa: E501
@@ -153,27 +183,53 @@ class MCPServer(FastMCP):
153
183
  self._initializer_fn: Callable | None = None
154
184
  self._did_init = False
155
185
  self._replaced_server = False
186
+ self._shutdown_has_run = False # Guard against double-execution of shutdown hook
156
187
 
157
188
  def _replace_with_init_server(self) -> None:
158
189
  """Replace the low-level server with init version when needed."""
159
190
  if self._replaced_server:
160
191
  return
161
192
 
162
- def _run_init(ctx: RequestContext | None = None) -> Any:
193
+ async def _run_init(ctx: object | None = None) -> None:
194
+ """Run the user initializer exactly once, with stdout redirected."""
163
195
  if self._initializer_fn is not None and not self._did_init:
164
196
  self._did_init = True
165
- # Redirect stdout to stderr during initialization to prevent
166
- # any library prints from corrupting the MCP protocol
197
+ # Prevent stdout from polluting the MCP protocol on stdio/HTTP
167
198
  with contextlib.redirect_stdout(sys.stderr):
168
- # Check if function accepts ctx parameter
169
199
  import inspect
170
200
 
171
- sig = inspect.signature(self._initializer_fn)
172
- if "ctx" in sig.parameters:
173
- return self._initializer_fn(ctx)
201
+ fn = self._initializer_fn
202
+ sig = inspect.signature(fn)
203
+ params = sig.parameters
204
+
205
+ ctx_param = params.get("ctx") or params.get("_ctx")
206
+ if ctx_param is not None:
207
+ if ctx_param.kind == inspect.Parameter.KEYWORD_ONLY:
208
+ result = fn(**{ctx_param.name: ctx})
209
+ else:
210
+ result = fn(ctx)
174
211
  else:
175
- # Call without ctx for simpler usage
176
- return self._initializer_fn()
212
+ required_params = [
213
+ p
214
+ for p in params.values()
215
+ if p.default is inspect._empty
216
+ and p.kind
217
+ in (
218
+ inspect.Parameter.POSITIONAL_ONLY,
219
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
220
+ inspect.Parameter.KEYWORD_ONLY,
221
+ )
222
+ ]
223
+ if required_params:
224
+ param_list = ", ".join(p.name for p in required_params)
225
+ raise TypeError(
226
+ "Initializer must accept no args or a single `ctx` argument; "
227
+ f"received required parameters: {param_list}"
228
+ )
229
+ result = fn()
230
+ if inspect.isawaitable(result):
231
+ await result
232
+ return None
177
233
  return None
178
234
 
179
235
  # Save the old server's handlers before replacing it
@@ -258,7 +314,22 @@ class MCPServer(FastMCP):
258
314
  self._register_hud_helpers()
259
315
  logger.info("Registered HUD helper endpoints at /hud/*")
260
316
 
261
- await super().run_async(transport=transport, show_banner=show_banner, **transport_kwargs)
317
+ try:
318
+ await super().run_async(
319
+ transport=transport, show_banner=show_banner, **transport_kwargs
320
+ )
321
+ finally:
322
+ # Fallback: ensure SIGTERM-triggered shutdown runs even when a custom
323
+ # lifespan bypasses our default fastmcp shutdown path.
324
+ global _sigterm_received
325
+ if self._shutdown_fn is not None and _sigterm_received and not self._shutdown_has_run:
326
+ try:
327
+ await self._shutdown_fn()
328
+ except Exception as e: # pragma: no cover - defensive logging
329
+ logger.error("Error during @mcp.shutdown (fallback): %s", e)
330
+ finally:
331
+ self._shutdown_has_run = True
332
+ _sigterm_received = False
262
333
 
263
334
  # Tool registration helper -- appends BaseTool to FastMCP
264
335
  def add_tool(self, obj: Any, **kwargs: Any) -> None:
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import types
5
+ from typing import Any, cast
6
+
7
+ from hud.server import MCPServer
8
+
9
+
10
+ def test_add_tool_accepts_base_tool(monkeypatch):
11
+ """If obj is BaseTool, its `.mcp` gets passed through to FastMCP.add_tool."""
12
+ # Stub hud.tools.base.BaseTool and capture FastMCP.add_tool calls
13
+ mod = types.ModuleType("hud.tools.base")
14
+
15
+ class FakeBaseTool:
16
+ """Stub type checked by isinstance() inside add_tool."""
17
+
18
+ # Tell the type checker we're mutating a dynamic module
19
+ mod_any = cast("Any", mod)
20
+ mod_any.BaseTool = FakeBaseTool
21
+ monkeypatch.setitem(sys.modules, "hud.tools.base", mod)
22
+
23
+ calls: dict[str, object | None] = {"obj": None, "kwargs": None}
24
+
25
+ def fake_super_add(self, obj: object, **kwargs: object) -> None: # keep runtime the same
26
+ calls["obj"] = obj
27
+ calls["kwargs"] = kwargs
28
+
29
+ monkeypatch.setattr("hud.server.server.FastMCP.add_tool", fake_super_add, raising=True)
30
+
31
+ mcp = MCPServer(name="AddTool")
32
+ sentinel = object()
33
+
34
+ class MyTool(FakeBaseTool):
35
+ def __init__(self) -> None:
36
+ self.mcp = sentinel
37
+
38
+ mcp.add_tool(MyTool(), extra="yes")
39
+ assert calls["obj"] is sentinel
40
+ assert isinstance(calls["kwargs"], dict)
41
+ assert calls["kwargs"]["extra"] == "yes"
42
+
43
+
44
+ def test_add_tool_plain_falls_back_to_super(monkeypatch):
45
+ """Non-BaseTool objects are passed unchanged to FastMCP.add_tool."""
46
+ calls = []
47
+
48
+ def fake_super_add(self, obj, **kwargs):
49
+ calls.append((obj, kwargs))
50
+
51
+ monkeypatch.setattr("hud.server.server.FastMCP.add_tool", fake_super_add, raising=True)
52
+
53
+ mcp = MCPServer(name="AddToolPlain")
54
+
55
+ async def fn(): # pragma: no cover - never awaited by FastMCP here
56
+ return "ok"
57
+
58
+ mcp.add_tool(fn, desc="x")
59
+ assert calls and calls[0][0] is fn
60
+ assert calls[0][1]["desc"] == "x"