hud-python 0.3.5__py3-none-any.whl → 0.4.0__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 -89
- hud/agents/__init__.py +17 -0
- hud/agents/art.py +101 -0
- hud/agents/base.py +599 -0
- hud/{mcp → agents}/claude.py +373 -321
- hud/{mcp → agents}/langchain.py +250 -250
- hud/agents/misc/__init__.py +7 -0
- hud/{agent → agents}/misc/response_agent.py +80 -80
- hud/{mcp → agents}/openai.py +352 -334
- hud/agents/openai_chat_generic.py +154 -0
- hud/{mcp → agents}/tests/__init__.py +1 -1
- hud/agents/tests/test_base.py +742 -0
- hud/agents/tests/test_claude.py +324 -0
- hud/{mcp → agents}/tests/test_client.py +363 -324
- hud/{mcp → agents}/tests/test_openai.py +237 -238
- hud/cli/__init__.py +617 -0
- hud/cli/__main__.py +8 -0
- hud/cli/analyze.py +371 -0
- hud/cli/analyze_metadata.py +230 -0
- hud/cli/build.py +427 -0
- hud/cli/clone.py +185 -0
- hud/cli/cursor.py +92 -0
- hud/cli/debug.py +392 -0
- hud/cli/docker_utils.py +83 -0
- hud/cli/init.py +281 -0
- hud/cli/interactive.py +353 -0
- hud/cli/mcp_server.py +756 -0
- hud/cli/pull.py +336 -0
- hud/cli/push.py +379 -0
- hud/cli/remote_runner.py +311 -0
- hud/cli/runner.py +160 -0
- hud/cli/tests/__init__.py +3 -0
- hud/cli/tests/test_analyze.py +284 -0
- hud/cli/tests/test_cli_init.py +265 -0
- hud/cli/tests/test_cli_main.py +27 -0
- hud/cli/tests/test_clone.py +142 -0
- hud/cli/tests/test_cursor.py +253 -0
- hud/cli/tests/test_debug.py +453 -0
- hud/cli/tests/test_mcp_server.py +139 -0
- hud/cli/tests/test_utils.py +388 -0
- hud/cli/utils.py +263 -0
- hud/clients/README.md +143 -0
- hud/clients/__init__.py +16 -0
- hud/clients/base.py +354 -0
- hud/clients/fastmcp.py +202 -0
- hud/clients/mcp_use.py +278 -0
- hud/clients/tests/__init__.py +1 -0
- hud/clients/tests/test_client_integration.py +111 -0
- hud/clients/tests/test_fastmcp.py +342 -0
- hud/clients/tests/test_protocol.py +188 -0
- hud/clients/utils/__init__.py +1 -0
- hud/clients/utils/retry_transport.py +160 -0
- hud/datasets.py +322 -192
- hud/misc/__init__.py +1 -0
- hud/{agent → misc}/claude_plays_pokemon.py +292 -283
- hud/otel/__init__.py +35 -0
- hud/otel/collector.py +142 -0
- hud/otel/config.py +164 -0
- hud/otel/context.py +536 -0
- hud/otel/exporters.py +366 -0
- hud/otel/instrumentation.py +97 -0
- hud/otel/processors.py +118 -0
- hud/otel/tests/__init__.py +1 -0
- hud/otel/tests/test_processors.py +197 -0
- hud/server/__init__.py +5 -5
- hud/server/context.py +114 -0
- hud/server/helper/__init__.py +5 -0
- hud/server/low_level.py +132 -0
- hud/server/server.py +166 -0
- hud/server/tests/__init__.py +3 -0
- hud/settings.py +73 -79
- hud/shared/__init__.py +5 -0
- hud/{exceptions.py → shared/exceptions.py} +180 -180
- hud/{server → shared}/requests.py +264 -264
- hud/shared/tests/test_exceptions.py +157 -0
- hud/{server → shared}/tests/test_requests.py +275 -275
- hud/telemetry/__init__.py +25 -30
- hud/telemetry/instrument.py +379 -0
- hud/telemetry/job.py +309 -141
- hud/telemetry/replay.py +74 -0
- hud/telemetry/trace.py +83 -0
- hud/tools/__init__.py +33 -34
- hud/tools/base.py +365 -65
- hud/tools/bash.py +161 -137
- hud/tools/computer/__init__.py +15 -13
- hud/tools/computer/anthropic.py +437 -420
- hud/tools/computer/hud.py +376 -334
- hud/tools/computer/openai.py +295 -292
- hud/tools/computer/settings.py +82 -0
- hud/tools/edit.py +314 -290
- hud/tools/executors/__init__.py +30 -30
- hud/tools/executors/base.py +539 -532
- hud/tools/executors/pyautogui.py +621 -619
- 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 -503
- hud/tools/{playwright_tool.py → playwright.py} +412 -379
- hud/tools/tests/__init__.py +3 -3
- hud/tools/tests/test_base.py +282 -0
- hud/tools/tests/test_bash.py +158 -152
- hud/tools/tests/test_bash_extended.py +197 -0
- hud/tools/tests/test_computer.py +425 -52
- hud/tools/tests/test_computer_actions.py +34 -34
- hud/tools/tests/test_edit.py +259 -240
- hud/tools/tests/test_init.py +27 -27
- hud/tools/tests/test_playwright_tool.py +183 -183
- hud/tools/tests/test_tools.py +145 -157
- hud/tools/tests/test_utils.py +156 -156
- hud/tools/types.py +72 -0
- hud/tools/utils.py +50 -50
- hud/types.py +136 -89
- hud/utils/__init__.py +10 -16
- hud/utils/async_utils.py +65 -0
- hud/utils/design.py +168 -0
- hud/utils/mcp.py +55 -0
- hud/utils/progress.py +149 -149
- hud/utils/telemetry.py +66 -66
- hud/utils/tests/test_async_utils.py +173 -0
- hud/utils/tests/test_init.py +17 -21
- hud/utils/tests/test_progress.py +261 -225
- hud/utils/tests/test_telemetry.py +82 -37
- hud/utils/tests/test_version.py +8 -8
- hud/version.py +7 -7
- hud_python-0.4.0.dist-info/METADATA +474 -0
- hud_python-0.4.0.dist-info/RECORD +132 -0
- hud_python-0.4.0.dist-info/entry_points.txt +3 -0
- {hud_python-0.3.5.dist-info → hud_python-0.4.0.dist-info}/licenses/LICENSE +21 -21
- hud/adapters/__init__.py +0 -8
- hud/adapters/claude/__init__.py +0 -5
- hud/adapters/claude/adapter.py +0 -180
- hud/adapters/claude/tests/__init__.py +0 -1
- hud/adapters/claude/tests/test_adapter.py +0 -519
- hud/adapters/common/__init__.py +0 -6
- hud/adapters/common/adapter.py +0 -178
- hud/adapters/common/tests/test_adapter.py +0 -289
- hud/adapters/common/types.py +0 -446
- hud/adapters/operator/__init__.py +0 -5
- hud/adapters/operator/adapter.py +0 -108
- hud/adapters/operator/tests/__init__.py +0 -1
- hud/adapters/operator/tests/test_adapter.py +0 -370
- hud/agent/__init__.py +0 -19
- hud/agent/base.py +0 -126
- hud/agent/claude.py +0 -271
- hud/agent/langchain.py +0 -215
- hud/agent/misc/__init__.py +0 -3
- hud/agent/operator.py +0 -268
- hud/agent/tests/__init__.py +0 -1
- hud/agent/tests/test_base.py +0 -202
- hud/env/__init__.py +0 -11
- hud/env/client.py +0 -35
- hud/env/docker_client.py +0 -349
- hud/env/environment.py +0 -446
- hud/env/local_docker_client.py +0 -358
- hud/env/remote_client.py +0 -212
- hud/env/remote_docker_client.py +0 -292
- hud/gym.py +0 -130
- hud/job.py +0 -773
- hud/mcp/__init__.py +0 -17
- hud/mcp/base.py +0 -631
- hud/mcp/client.py +0 -312
- hud/mcp/tests/test_base.py +0 -512
- hud/mcp/tests/test_claude.py +0 -294
- hud/task.py +0 -149
- hud/taskset.py +0 -237
- hud/telemetry/_trace.py +0 -347
- hud/telemetry/context.py +0 -230
- hud/telemetry/exporter.py +0 -575
- hud/telemetry/instrumentation/__init__.py +0 -3
- hud/telemetry/instrumentation/mcp.py +0 -259
- hud/telemetry/instrumentation/registry.py +0 -59
- hud/telemetry/mcp_models.py +0 -270
- hud/telemetry/tests/__init__.py +0 -1
- hud/telemetry/tests/test_context.py +0 -210
- hud/telemetry/tests/test_trace.py +0 -312
- hud/tools/helper/README.md +0 -56
- hud/tools/helper/__init__.py +0 -9
- hud/tools/helper/mcp_server.py +0 -78
- hud/tools/helper/server_initialization.py +0 -115
- hud/tools/helper/utils.py +0 -58
- hud/trajectory.py +0 -94
- hud/utils/agent.py +0 -37
- hud/utils/common.py +0 -256
- hud/utils/config.py +0 -120
- hud/utils/deprecation.py +0 -115
- hud/utils/misc.py +0 -53
- hud/utils/tests/test_common.py +0 -277
- hud/utils/tests/test_config.py +0 -129
- hud_python-0.3.5.dist-info/METADATA +0 -284
- hud_python-0.3.5.dist-info/RECORD +0 -120
- /hud/{adapters/common → shared}/tests/__init__.py +0 -0
- {hud_python-0.3.5.dist-info → hud_python-0.4.0.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 .
|
|
4
|
-
|
|
5
|
-
__all__ = ["
|
|
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()
|
hud/server/low_level.py
ADDED
|
@@ -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)
|
hud/server/tests/__init__.py
CHANGED