hud-python 0.3.5__py3-none-any.whl → 0.4.1__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 (192) hide show
  1. hud/__init__.py +22 -89
  2. hud/agents/__init__.py +15 -0
  3. hud/agents/art.py +101 -0
  4. hud/agents/base.py +599 -0
  5. hud/{mcp → agents}/claude.py +373 -321
  6. hud/{mcp → agents}/langchain.py +250 -250
  7. hud/agents/misc/__init__.py +7 -0
  8. hud/{agent → agents}/misc/response_agent.py +80 -80
  9. hud/{mcp → agents}/openai.py +352 -334
  10. hud/agents/openai_chat_generic.py +154 -0
  11. hud/{mcp → agents}/tests/__init__.py +1 -1
  12. hud/agents/tests/test_base.py +742 -0
  13. hud/agents/tests/test_claude.py +324 -0
  14. hud/{mcp → agents}/tests/test_client.py +363 -324
  15. hud/{mcp → agents}/tests/test_openai.py +237 -238
  16. hud/cli/__init__.py +617 -0
  17. hud/cli/__main__.py +8 -0
  18. hud/cli/analyze.py +371 -0
  19. hud/cli/analyze_metadata.py +230 -0
  20. hud/cli/build.py +427 -0
  21. hud/cli/clone.py +185 -0
  22. hud/cli/cursor.py +92 -0
  23. hud/cli/debug.py +392 -0
  24. hud/cli/docker_utils.py +83 -0
  25. hud/cli/init.py +281 -0
  26. hud/cli/interactive.py +353 -0
  27. hud/cli/mcp_server.py +756 -0
  28. hud/cli/pull.py +336 -0
  29. hud/cli/push.py +370 -0
  30. hud/cli/remote_runner.py +311 -0
  31. hud/cli/runner.py +160 -0
  32. hud/cli/tests/__init__.py +3 -0
  33. hud/cli/tests/test_analyze.py +284 -0
  34. hud/cli/tests/test_cli_init.py +265 -0
  35. hud/cli/tests/test_cli_main.py +27 -0
  36. hud/cli/tests/test_clone.py +142 -0
  37. hud/cli/tests/test_cursor.py +253 -0
  38. hud/cli/tests/test_debug.py +453 -0
  39. hud/cli/tests/test_mcp_server.py +139 -0
  40. hud/cli/tests/test_utils.py +388 -0
  41. hud/cli/utils.py +263 -0
  42. hud/clients/README.md +143 -0
  43. hud/clients/__init__.py +16 -0
  44. hud/clients/base.py +379 -0
  45. hud/clients/fastmcp.py +222 -0
  46. hud/clients/mcp_use.py +278 -0
  47. hud/clients/tests/__init__.py +1 -0
  48. hud/clients/tests/test_client_integration.py +111 -0
  49. hud/clients/tests/test_fastmcp.py +342 -0
  50. hud/clients/tests/test_protocol.py +188 -0
  51. hud/clients/utils/__init__.py +1 -0
  52. hud/clients/utils/retry_transport.py +160 -0
  53. hud/datasets.py +322 -192
  54. hud/misc/__init__.py +1 -0
  55. hud/{agent → misc}/claude_plays_pokemon.py +292 -283
  56. hud/otel/__init__.py +35 -0
  57. hud/otel/collector.py +142 -0
  58. hud/otel/config.py +164 -0
  59. hud/otel/context.py +536 -0
  60. hud/otel/exporters.py +366 -0
  61. hud/otel/instrumentation.py +97 -0
  62. hud/otel/processors.py +118 -0
  63. hud/otel/tests/__init__.py +1 -0
  64. hud/otel/tests/test_processors.py +197 -0
  65. hud/server/__init__.py +5 -5
  66. hud/server/context.py +114 -0
  67. hud/server/helper/__init__.py +5 -0
  68. hud/server/low_level.py +132 -0
  69. hud/server/server.py +166 -0
  70. hud/server/tests/__init__.py +3 -0
  71. hud/settings.py +73 -79
  72. hud/shared/__init__.py +5 -0
  73. hud/{exceptions.py → shared/exceptions.py} +180 -180
  74. hud/{server → shared}/requests.py +264 -264
  75. hud/shared/tests/test_exceptions.py +157 -0
  76. hud/{server → shared}/tests/test_requests.py +275 -275
  77. hud/telemetry/__init__.py +25 -30
  78. hud/telemetry/instrument.py +379 -0
  79. hud/telemetry/job.py +309 -141
  80. hud/telemetry/replay.py +74 -0
  81. hud/telemetry/trace.py +83 -0
  82. hud/tools/__init__.py +33 -34
  83. hud/tools/base.py +365 -65
  84. hud/tools/bash.py +161 -137
  85. hud/tools/computer/__init__.py +15 -13
  86. hud/tools/computer/anthropic.py +437 -420
  87. hud/tools/computer/hud.py +376 -334
  88. hud/tools/computer/openai.py +295 -292
  89. hud/tools/computer/settings.py +82 -0
  90. hud/tools/edit.py +314 -290
  91. hud/tools/executors/__init__.py +30 -30
  92. hud/tools/executors/base.py +539 -532
  93. hud/tools/executors/pyautogui.py +621 -619
  94. hud/tools/executors/tests/__init__.py +1 -1
  95. hud/tools/executors/tests/test_base_executor.py +338 -338
  96. hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
  97. hud/tools/executors/xdo.py +511 -503
  98. hud/tools/{playwright_tool.py → playwright.py} +412 -379
  99. hud/tools/tests/__init__.py +3 -3
  100. hud/tools/tests/test_base.py +282 -0
  101. hud/tools/tests/test_bash.py +158 -152
  102. hud/tools/tests/test_bash_extended.py +197 -0
  103. hud/tools/tests/test_computer.py +425 -52
  104. hud/tools/tests/test_computer_actions.py +34 -34
  105. hud/tools/tests/test_edit.py +259 -240
  106. hud/tools/tests/test_init.py +27 -27
  107. hud/tools/tests/test_playwright_tool.py +183 -183
  108. hud/tools/tests/test_tools.py +145 -157
  109. hud/tools/tests/test_utils.py +156 -156
  110. hud/tools/types.py +72 -0
  111. hud/tools/utils.py +50 -50
  112. hud/types.py +136 -89
  113. hud/utils/__init__.py +10 -16
  114. hud/utils/async_utils.py +65 -0
  115. hud/utils/design.py +168 -0
  116. hud/utils/mcp.py +55 -0
  117. hud/utils/progress.py +149 -149
  118. hud/utils/telemetry.py +66 -66
  119. hud/utils/tests/test_async_utils.py +173 -0
  120. hud/utils/tests/test_init.py +17 -21
  121. hud/utils/tests/test_progress.py +261 -225
  122. hud/utils/tests/test_telemetry.py +82 -37
  123. hud/utils/tests/test_version.py +8 -8
  124. hud/version.py +7 -7
  125. hud_python-0.4.1.dist-info/METADATA +476 -0
  126. hud_python-0.4.1.dist-info/RECORD +132 -0
  127. hud_python-0.4.1.dist-info/entry_points.txt +3 -0
  128. {hud_python-0.3.5.dist-info → hud_python-0.4.1.dist-info}/licenses/LICENSE +21 -21
  129. hud/adapters/__init__.py +0 -8
  130. hud/adapters/claude/__init__.py +0 -5
  131. hud/adapters/claude/adapter.py +0 -180
  132. hud/adapters/claude/tests/__init__.py +0 -1
  133. hud/adapters/claude/tests/test_adapter.py +0 -519
  134. hud/adapters/common/__init__.py +0 -6
  135. hud/adapters/common/adapter.py +0 -178
  136. hud/adapters/common/tests/test_adapter.py +0 -289
  137. hud/adapters/common/types.py +0 -446
  138. hud/adapters/operator/__init__.py +0 -5
  139. hud/adapters/operator/adapter.py +0 -108
  140. hud/adapters/operator/tests/__init__.py +0 -1
  141. hud/adapters/operator/tests/test_adapter.py +0 -370
  142. hud/agent/__init__.py +0 -19
  143. hud/agent/base.py +0 -126
  144. hud/agent/claude.py +0 -271
  145. hud/agent/langchain.py +0 -215
  146. hud/agent/misc/__init__.py +0 -3
  147. hud/agent/operator.py +0 -268
  148. hud/agent/tests/__init__.py +0 -1
  149. hud/agent/tests/test_base.py +0 -202
  150. hud/env/__init__.py +0 -11
  151. hud/env/client.py +0 -35
  152. hud/env/docker_client.py +0 -349
  153. hud/env/environment.py +0 -446
  154. hud/env/local_docker_client.py +0 -358
  155. hud/env/remote_client.py +0 -212
  156. hud/env/remote_docker_client.py +0 -292
  157. hud/gym.py +0 -130
  158. hud/job.py +0 -773
  159. hud/mcp/__init__.py +0 -17
  160. hud/mcp/base.py +0 -631
  161. hud/mcp/client.py +0 -312
  162. hud/mcp/tests/test_base.py +0 -512
  163. hud/mcp/tests/test_claude.py +0 -294
  164. hud/task.py +0 -149
  165. hud/taskset.py +0 -237
  166. hud/telemetry/_trace.py +0 -347
  167. hud/telemetry/context.py +0 -230
  168. hud/telemetry/exporter.py +0 -575
  169. hud/telemetry/instrumentation/__init__.py +0 -3
  170. hud/telemetry/instrumentation/mcp.py +0 -259
  171. hud/telemetry/instrumentation/registry.py +0 -59
  172. hud/telemetry/mcp_models.py +0 -270
  173. hud/telemetry/tests/__init__.py +0 -1
  174. hud/telemetry/tests/test_context.py +0 -210
  175. hud/telemetry/tests/test_trace.py +0 -312
  176. hud/tools/helper/README.md +0 -56
  177. hud/tools/helper/__init__.py +0 -9
  178. hud/tools/helper/mcp_server.py +0 -78
  179. hud/tools/helper/server_initialization.py +0 -115
  180. hud/tools/helper/utils.py +0 -58
  181. hud/trajectory.py +0 -94
  182. hud/utils/agent.py +0 -37
  183. hud/utils/common.py +0 -256
  184. hud/utils/config.py +0 -120
  185. hud/utils/deprecation.py +0 -115
  186. hud/utils/misc.py +0 -53
  187. hud/utils/tests/test_common.py +0 -277
  188. hud/utils/tests/test_config.py +0 -129
  189. hud_python-0.3.5.dist-info/METADATA +0 -284
  190. hud_python-0.3.5.dist-info/RECORD +0 -120
  191. /hud/{adapters/common → shared}/tests/__init__.py +0 -0
  192. {hud_python-0.3.5.dist-info → hud_python-0.4.1.dist-info}/WHEEL +0 -0
@@ -0,0 +1,197 @@
1
+ """Tests for OpenTelemetry processors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ from hud.otel.processors import HudEnrichmentProcessor
8
+
9
+
10
+ class TestHudEnrichmentProcessor:
11
+ """Test HudEnrichmentProcessor."""
12
+
13
+ def test_on_start_with_run_id(self):
14
+ """Test on_start with current task run ID."""
15
+
16
+ processor = HudEnrichmentProcessor()
17
+
18
+ # Mock span
19
+ span = MagicMock()
20
+ span.set_attribute = MagicMock()
21
+ span.is_recording.return_value = True
22
+
23
+ # Mock baggage to return run ID
24
+ parent_context = {}
25
+ with patch("hud.otel.processors.baggage.get_baggage") as mock_get_baggage:
26
+ # Return run ID for task_run_id, None for job_id
27
+ mock_get_baggage.side_effect = (
28
+ lambda key, context: "test-run-123" if key == "hud.task_run_id" else None
29
+ )
30
+ processor.on_start(span, parent_context)
31
+
32
+ # Verify attribute was set
33
+ span.set_attribute.assert_called_with("hud.task_run_id", "test-run-123")
34
+
35
+ def test_on_start_no_run_id(self):
36
+ """Test on_start without current task run ID."""
37
+
38
+ processor = HudEnrichmentProcessor()
39
+
40
+ # Mock span
41
+ span = MagicMock()
42
+ span.set_attribute = MagicMock()
43
+ span.is_recording.return_value = True
44
+ span.name = "test_span"
45
+
46
+ # Set up attributes to return None (not matching any step type)
47
+ span.attributes = {}
48
+
49
+ # Mock baggage to return None
50
+ parent_context = {}
51
+ with patch("hud.otel.processors.baggage.get_baggage", return_value=None):
52
+ processor.on_start(span, parent_context)
53
+
54
+ # Verify only step count attributes were set (no run_id or job_id)
55
+ calls = span.set_attribute.call_args_list
56
+ set_attrs = {call[0][0] for call in calls}
57
+
58
+ # Should have step counts but not run_id/job_id
59
+ assert "hud.task_run_id" not in set_attrs
60
+ assert "hud.job_id" not in set_attrs
61
+ assert "hud.base_mcp_steps" in set_attrs
62
+ assert "hud.mcp_tool_steps" in set_attrs
63
+ assert "hud.agent_steps" in set_attrs
64
+
65
+ def test_on_end(self):
66
+ """Test on_end does nothing."""
67
+
68
+ processor = HudEnrichmentProcessor()
69
+ span = MagicMock()
70
+
71
+ # Should not raise
72
+ processor.on_end(span)
73
+
74
+ def test_shutdown(self):
75
+ """Test shutdown does nothing."""
76
+
77
+ processor = HudEnrichmentProcessor()
78
+
79
+ # Should not raise
80
+ processor.shutdown()
81
+
82
+ def test_force_flush(self):
83
+ """Test force_flush returns True."""
84
+
85
+ processor = HudEnrichmentProcessor()
86
+
87
+ # Should return True
88
+ result = processor.force_flush()
89
+ assert result is True
90
+
91
+ def test_on_start_with_job_id(self):
92
+ """Test on_start with job ID in baggage."""
93
+
94
+ processor = HudEnrichmentProcessor()
95
+
96
+ # Mock span
97
+ span = MagicMock()
98
+ span.set_attribute = MagicMock()
99
+ span.is_recording.return_value = True
100
+
101
+ # Mock baggage with job ID
102
+ parent_context = {}
103
+ with patch("hud.otel.processors.baggage.get_baggage") as mock_get_baggage:
104
+ # Return None for task_run_id, job-123 for job_id
105
+ mock_get_baggage.side_effect = (
106
+ lambda key, context: "job-123" if key == "hud.job_id" else None
107
+ )
108
+ processor.on_start(span, parent_context)
109
+
110
+ # Verify job ID attribute was set
111
+ span.set_attribute.assert_called_with("hud.job_id", "job-123")
112
+
113
+ def test_on_start_exception_handling(self):
114
+ """Test on_start handles exceptions gracefully."""
115
+
116
+ processor = HudEnrichmentProcessor()
117
+
118
+ # Mock span that raises exception
119
+ span = MagicMock()
120
+ span.is_recording.side_effect = Exception("Test error")
121
+
122
+ # Should not raise
123
+ processor.on_start(span, parent_context=None)
124
+
125
+ def test_on_start_exception_handling_extended(self):
126
+ """Test that exceptions in on_start are caught and logged."""
127
+ from hud.otel.processors import HudEnrichmentProcessor
128
+
129
+ processor = HudEnrichmentProcessor()
130
+
131
+ # Create a mock span that raises when setting attributes
132
+ mock_span = MagicMock()
133
+ mock_span.is_recording.return_value = True
134
+ mock_span.set_attribute.side_effect = RuntimeError("Attribute error")
135
+
136
+ parent_context = {}
137
+
138
+ # Patch logger and baggage to force an exception when setting attribute
139
+ with (
140
+ patch("hud.otel.processors.logger") as mock_logger,
141
+ patch("hud.otel.processors.baggage.get_baggage", return_value="test-id"),
142
+ ):
143
+ # Should not raise, exception should be caught
144
+ processor.on_start(mock_span, parent_context)
145
+
146
+ # Verify logger.debug was called with the exception
147
+ mock_logger.debug.assert_called_once()
148
+ args = mock_logger.debug.call_args[0]
149
+ assert "HudEnrichmentProcessor.on_start error" in args[0]
150
+ assert "Attribute error" in str(args[1])
151
+
152
+ def test_on_start_with_baggage_get_exception(self):
153
+ """Test exception handling when baggage.get_baggage fails for task_run_id."""
154
+ processor = HudEnrichmentProcessor()
155
+
156
+ mock_span = MagicMock()
157
+ mock_span.is_recording.return_value = True
158
+
159
+ parent_context = {}
160
+
161
+ # Make baggage.get_baggage raise an exception for task_run_id
162
+ with (
163
+ patch(
164
+ "hud.otel.processors.baggage.get_baggage",
165
+ side_effect=ValueError("Context error"),
166
+ ),
167
+ patch("hud.otel.processors.logger") as mock_logger,
168
+ ):
169
+ # Should not raise
170
+ processor.on_start(mock_span, parent_context)
171
+
172
+ # Verify logger.debug was called
173
+ mock_logger.debug.assert_called_once()
174
+ args = mock_logger.debug.call_args[0]
175
+ assert "Context error" in str(args[1])
176
+
177
+ def test_on_start_with_baggage_exception(self):
178
+ """Test exception handling when baggage.get_baggage fails."""
179
+ processor = HudEnrichmentProcessor()
180
+
181
+ mock_span = MagicMock()
182
+ mock_span.is_recording.return_value = True
183
+
184
+ parent_context = {}
185
+
186
+ # Make baggage.get_baggage raise an exception
187
+ with (
188
+ patch("hud.otel.processors.baggage.get_baggage", side_effect=KeyError("Baggage error")),
189
+ patch("hud.otel.processors.logger") as mock_logger,
190
+ ):
191
+ # Should not raise
192
+ processor.on_start(mock_span, parent_context)
193
+
194
+ # Verify logger.debug was called
195
+ mock_logger.debug.assert_called_once()
196
+ args = mock_logger.debug.call_args[0]
197
+ assert "Baggage error" in str(args[1])
hud/server/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
- from __future__ import annotations
2
-
3
- from .requests import make_request, make_request_sync
4
-
5
- __all__ = ["make_request", "make_request_sync"]
1
+ from __future__ import annotations
2
+
3
+ from .server import MCPServer
4
+
5
+ __all__ = ["MCPServer"]
hud/server/context.py ADDED
@@ -0,0 +1,114 @@
1
+ """
2
+ HUD context helpers for persistent state across hot-reloads.
3
+
4
+ Provides utilities for creating shared context servers that survive
5
+ code reloads during development.
6
+
7
+ Usage in your environment:
8
+ # In your context_server.py:
9
+ from hud.server.context import serve_context
10
+
11
+ class MyContext:
12
+ def __init__(self):
13
+ self.state = {}
14
+ def startup(self):
15
+ # Initialize resources
16
+ pass
17
+
18
+ if __name__ == "__main__":
19
+ serve_context(MyContext())
20
+
21
+ # In your MCP server:
22
+ from hud.server.context import attach_context
23
+ ctx = attach_context() # Gets the persistent context
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import logging
30
+ import os
31
+ from multiprocessing.managers import BaseManager
32
+ from typing import Any
33
+
34
+ logger = logging.getLogger(__name__)
35
+ # Default Unix socket path (can be overridden with HUD_CTX_SOCK)
36
+ DEFAULT_SOCK_PATH = "/tmp/hud_ctx.sock" # noqa: S108
37
+
38
+
39
+ def serve_context(
40
+ context_instance: Any, sock_path: str | None = None, authkey: bytes = b"hud-context"
41
+ ) -> BaseManager:
42
+ """
43
+ Serve a context object via multiprocessing Manager.
44
+
45
+ Args:
46
+ context_instance: The context object to serve
47
+ sock_path: Unix socket path (defaults to HUD_CTX_SOCK env var or /tmp/hud_ctx.sock)
48
+ authkey: Authentication key for the manager
49
+
50
+ Returns:
51
+ The manager instance (can be used to shutdown)
52
+ """
53
+ sock_path = sock_path or os.getenv("HUD_CTX_SOCK", DEFAULT_SOCK_PATH)
54
+
55
+ class ContextManager(BaseManager):
56
+ pass
57
+
58
+ ContextManager.register("get_context", callable=lambda: context_instance)
59
+
60
+ manager = ContextManager(address=sock_path, authkey=authkey)
61
+ manager.start()
62
+
63
+ return manager
64
+
65
+
66
+ def attach_context(sock_path: str | None = None, authkey: bytes = b"hud-context") -> Any:
67
+ """
68
+ Attach to a running context server.
69
+
70
+ Args:
71
+ sock_path: Unix socket path (defaults to HUD_CTX_SOCK env var or /tmp/hud_ctx.sock)
72
+ authkey: Authentication key for the manager
73
+
74
+ Returns:
75
+ The shared context object
76
+ """
77
+ sock_path = sock_path or os.getenv("HUD_CTX_SOCK", DEFAULT_SOCK_PATH)
78
+
79
+ class ContextManager(BaseManager):
80
+ pass
81
+
82
+ ContextManager.register("get_context")
83
+
84
+ manager = ContextManager(address=sock_path, authkey=authkey)
85
+ manager.connect()
86
+
87
+ return manager.get_context() # type: ignore
88
+
89
+
90
+ async def run_context_server(
91
+ context_instance: Any, sock_path: str | None = None, authkey: bytes = b"hud-context"
92
+ ) -> None:
93
+ """
94
+ Run a context server until interrupted.
95
+
96
+ Args:
97
+ context_instance: The context object to serve
98
+ sock_path: Unix socket path
99
+ authkey: Authentication key
100
+ """
101
+ sock_path = sock_path or os.getenv("HUD_CTX_SOCK", DEFAULT_SOCK_PATH)
102
+
103
+ logger.info("[Context Server] Starting on %s...", sock_path)
104
+
105
+ # Start the manager
106
+ manager = serve_context(context_instance, sock_path, authkey)
107
+ logger.info("[Context Server] Ready on %s", sock_path)
108
+
109
+ # Wait forever (until killed)
110
+ try:
111
+ await asyncio.Event().wait()
112
+ except KeyboardInterrupt:
113
+ logger.info("[Context Server] Shutting down...")
114
+ manager.shutdown()
@@ -0,0 +1,5 @@
1
+ """Helper sub-package: utilities, registration helpers, shims."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = []
@@ -0,0 +1,132 @@
1
+ """Custom low-level MCP server that supports per-server initialization hooks.
2
+
3
+ This duplicates the upstream `mcp.server.lowlevel.server.Server.run` logic so we
4
+ can inject our own `InitSession` subtype without touching global state.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from contextlib import AsyncExitStack
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ import anyio
13
+ import mcp.types as types
14
+ from fastmcp.server.low_level import LowLevelServer as _BaseLL
15
+ from mcp.server.lowlevel.server import (
16
+ logger,
17
+ )
18
+ from mcp.server.session import ServerSession
19
+ from mcp.shared.context import RequestContext
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Awaitable, Callable
23
+
24
+ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
25
+ from mcp.server.models import InitializationOptions
26
+ from mcp.shared.message import SessionMessage
27
+ from mcp.shared.session import RequestResponder
28
+
29
+
30
+ class InitSession(ServerSession):
31
+ """ServerSession that runs a one-time `init_fn(ctx)` on *initialize*."""
32
+
33
+ def __init__(
34
+ self,
35
+ read_stream: MemoryObjectReceiveStream[SessionMessage | Exception],
36
+ write_stream: MemoryObjectSendStream[SessionMessage],
37
+ init_opts: InitializationOptions,
38
+ *,
39
+ init_fn: Callable[[RequestContext], Awaitable[None]] | None = None,
40
+ stateless: bool = False,
41
+ ) -> None:
42
+ super().__init__(read_stream, write_stream, init_opts, stateless=stateless)
43
+ self._init_fn = init_fn
44
+ self._did_init = stateless # skip when running stateless
45
+
46
+ # pylint: disable=protected-access # we need to hook into internal method
47
+ async def _received_request(
48
+ self,
49
+ responder: RequestResponder[types.ClientRequest, types.ServerResult],
50
+ ) -> types.ServerResult | None:
51
+ # Intercept initialize
52
+ if (
53
+ isinstance(responder.request.root, types.InitializeRequest)
54
+ and not self._did_init
55
+ and self._init_fn is not None
56
+ ):
57
+ req = responder.request.root
58
+ ctx = RequestContext[
59
+ "ServerSession",
60
+ dict[str, Any],
61
+ types.InitializeRequest,
62
+ ](
63
+ request_id=req.id, # type: ignore[attr-defined]
64
+ meta=req.params.meta,
65
+ session=self,
66
+ lifespan_context={},
67
+ request=req,
68
+ )
69
+ try:
70
+ await self._init_fn(ctx)
71
+ except Exception as exc:
72
+ token = getattr(req.params.meta, "progressToken", None)
73
+ if token is not None:
74
+ await self.send_progress_notification(
75
+ progress_token=token,
76
+ progress=0,
77
+ total=100,
78
+ message=f"Initialization failed: {exc}",
79
+ )
80
+ raise
81
+ finally:
82
+ self._did_init = True
83
+ # fall through to original behaviour
84
+ return await super()._received_request(responder)
85
+
86
+
87
+ class LowLevelServerWithInit(_BaseLL):
88
+ """LowLevelServer that uses :class:`InitSession` instead of `ServerSession`."""
89
+
90
+ def __init__(
91
+ self,
92
+ *args: Any,
93
+ init_fn: Callable[[RequestContext], Awaitable[None]] | None = None,
94
+ **kwargs: Any,
95
+ ) -> None:
96
+ super().__init__(*args, **kwargs)
97
+ self._init_fn = init_fn
98
+
99
+ async def run(
100
+ self,
101
+ read_stream: MemoryObjectReceiveStream[SessionMessage | Exception],
102
+ write_stream: MemoryObjectSendStream[SessionMessage],
103
+ initialization_options: InitializationOptions,
104
+ *,
105
+ raise_exceptions: bool = False,
106
+ stateless: bool = False,
107
+ ) -> None:
108
+ """Copy of upstream run with InitSession injected."""
109
+
110
+ async with AsyncExitStack() as stack:
111
+ lifespan_context = await stack.enter_async_context(self.lifespan(self))
112
+ session = await stack.enter_async_context(
113
+ InitSession(
114
+ read_stream,
115
+ write_stream,
116
+ initialization_options,
117
+ stateless=stateless,
118
+ init_fn=self._init_fn,
119
+ )
120
+ )
121
+
122
+ async with anyio.create_task_group() as tg:
123
+ async for message in session.incoming_messages:
124
+ logger.debug("Received message: %s", message)
125
+
126
+ tg.start_soon(
127
+ self._handle_message,
128
+ message,
129
+ session,
130
+ lifespan_context,
131
+ raise_exceptions,
132
+ )
hud/server/server.py ADDED
@@ -0,0 +1,166 @@
1
+ """HUD server helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import signal
9
+ import sys
10
+ from contextlib import asynccontextmanager
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ import anyio
14
+ from fastmcp.server.server import FastMCP, Transport
15
+
16
+ from hud.server.low_level import LowLevelServerWithInit
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import AsyncGenerator, Callable
20
+
21
+ from mcp.shared.context import RequestContext
22
+
23
+ __all__ = ["MCPServer"]
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def _run_with_sigterm(coro_fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
29
+ """Run *coro_fn* via anyio.run() and cancel on SIGTERM or SIGINT (POSIX)."""
30
+
31
+ async def _runner() -> None:
32
+ stop_evt: asyncio.Event | None = None
33
+ if sys.platform != "win32" and os.getenv("FASTMCP_DISABLE_SIGTERM_HANDLER") != "1":
34
+ loop = asyncio.get_running_loop()
35
+ stop_evt = asyncio.Event()
36
+
37
+ # Handle both SIGTERM and SIGINT for graceful shutdown
38
+ if signal.getsignal(signal.SIGTERM) is signal.SIG_DFL:
39
+ loop.add_signal_handler(signal.SIGTERM, stop_evt.set)
40
+ if signal.getsignal(signal.SIGINT) is signal.SIG_DFL:
41
+ loop.add_signal_handler(signal.SIGINT, stop_evt.set)
42
+
43
+ async with anyio.create_task_group() as tg:
44
+ tg.start_soon(coro_fn, *args, **kwargs)
45
+
46
+ if stop_evt is not None:
47
+
48
+ async def _watch() -> None:
49
+ logger.info("Waiting for SIGTERM or SIGINT")
50
+ if stop_evt is not None:
51
+ await stop_evt.wait()
52
+ logger.debug("Received shutdown signal, cancelling tasks...")
53
+ tg.cancel_scope.cancel()
54
+
55
+ tg.start_soon(_watch)
56
+
57
+ anyio.run(_runner)
58
+
59
+
60
+ class MCPServer(FastMCP):
61
+ """FastMCP wrapper that adds helpful functionality for dockerized environments.
62
+ This works with any MCP client, and adds just a few extra server-side features:
63
+ 1. SIGTERM handling for graceful shutdown in container runtimes.
64
+ 2. ``@MCPServer.initialize`` decorator that registers an async initializer
65
+ executed during the MCP *initialize* request. The initializer function receives
66
+ a single ``ctx`` parameter (RequestContext) from which you can access:
67
+ - ``ctx.session``: The MCP ServerSession
68
+ - ``ctx.meta.progressToken``: Token for progress notifications (if provided)
69
+ - ``ctx.session.client_params.clientInfo``: Client information
70
+ 3. ``@MCPServer.shutdown`` decorator that registers a coroutine to run during
71
+ server teardown, after all lifespan contexts have exited.
72
+ 4. Enhanced ``add_tool`` that accepts instances of
73
+ :class:`hud.tools.base.BaseTool` which are classes that implement the
74
+ FastMCP ``FunctionTool`` interface.
75
+ """
76
+
77
+ def __init__(self, *, name: str | None = None, **fastmcp_kwargs: Any) -> None:
78
+ # Store shutdown function placeholder before super().__init__
79
+ self._shutdown_fn: Callable | None = None
80
+
81
+ # Inject custom lifespan if user did not supply one
82
+ if "lifespan" not in fastmcp_kwargs:
83
+
84
+ @asynccontextmanager
85
+ async def _lifespan(_: Any) -> AsyncGenerator[dict[str, Any], None]:
86
+ try:
87
+ yield {}
88
+ finally:
89
+ if self._shutdown_fn is not None:
90
+ await self._shutdown_fn()
91
+
92
+ fastmcp_kwargs["lifespan"] = _lifespan
93
+
94
+ super().__init__(name=name, **fastmcp_kwargs)
95
+ self._initializer_fn: Callable | None = None
96
+ self._did_init = False
97
+
98
+ # Replace FastMCP's low-level server with our version that supports
99
+ # per-server initialization hooks
100
+ def _run_init(ctx: RequestContext) -> Any:
101
+ if self._initializer_fn is not None and not self._did_init:
102
+ self._did_init = True
103
+ return self._initializer_fn(ctx)
104
+ return None
105
+
106
+ # Save the old server's handlers before replacing it
107
+ old_request_handlers = self._mcp_server.request_handlers
108
+ old_notification_handlers = self._mcp_server.notification_handlers
109
+
110
+ self._mcp_server = LowLevelServerWithInit(
111
+ name=self.name,
112
+ version=self.version,
113
+ instructions=self.instructions,
114
+ lifespan=self._mcp_server.lifespan, # reuse the existing lifespan
115
+ init_fn=_run_init,
116
+ )
117
+
118
+ # Copy handlers from the old server to the new one
119
+ self._mcp_server.request_handlers = old_request_handlers
120
+ self._mcp_server.notification_handlers = old_notification_handlers
121
+
122
+ # Initializer decorator: runs on the initialize request
123
+ # The decorated function receives a RequestContext object with access to:
124
+ # - ctx.session: The MCP ServerSession
125
+ # - ctx.meta.progressToken: Progress token (if provided by client)
126
+ # - ctx.session.client_params.clientInfo: Client information
127
+ def initialize(self, fn: Callable | None = None) -> Callable | None:
128
+ def decorator(func: Callable) -> Callable:
129
+ self._initializer_fn = func
130
+ return func
131
+
132
+ return decorator(fn) if fn else decorator
133
+
134
+ # Shutdown decorator: runs after server stops
135
+ # Supports dockerized SIGTERM handling
136
+ def shutdown(self, fn: Callable | None = None) -> Callable | None:
137
+ def decorator(func: Callable) -> Callable:
138
+ self._shutdown_fn = func
139
+ return func
140
+
141
+ return decorator(fn) if fn else decorator
142
+
143
+ # Run with SIGTERM handling and custom initialization
144
+ def run(
145
+ self,
146
+ transport: Transport | None = None,
147
+ show_banner: bool = True,
148
+ **transport_kwargs: Any,
149
+ ) -> None:
150
+ if transport is None:
151
+ transport = "stdio"
152
+
153
+ async def _bootstrap() -> None:
154
+ await self.run_async(transport=transport, show_banner=show_banner, **transport_kwargs) # type: ignore[arg-type]
155
+
156
+ _run_with_sigterm(_bootstrap)
157
+
158
+ # Tool registration helper -- appends BaseTool to FastMCP
159
+ def add_tool(self, obj: Any, **kwargs: Any) -> None:
160
+ from hud.tools.base import BaseTool
161
+
162
+ if isinstance(obj, BaseTool):
163
+ super().add_tool(obj.mcp, **kwargs)
164
+ return
165
+
166
+ super().add_tool(obj, **kwargs)
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __all__ = []