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.

Files changed (130) hide show
  1. hud/__init__.py +22 -22
  2. hud/agents/__init__.py +13 -15
  3. hud/agents/base.py +599 -599
  4. hud/agents/claude.py +373 -373
  5. hud/agents/langchain.py +261 -250
  6. hud/agents/misc/__init__.py +7 -7
  7. hud/agents/misc/response_agent.py +82 -80
  8. hud/agents/openai.py +352 -352
  9. hud/agents/openai_chat_generic.py +154 -154
  10. hud/agents/tests/__init__.py +1 -1
  11. hud/agents/tests/test_base.py +742 -742
  12. hud/agents/tests/test_claude.py +324 -324
  13. hud/agents/tests/test_client.py +363 -363
  14. hud/agents/tests/test_openai.py +237 -237
  15. hud/cli/__init__.py +617 -617
  16. hud/cli/__main__.py +8 -8
  17. hud/cli/analyze.py +371 -371
  18. hud/cli/analyze_metadata.py +230 -230
  19. hud/cli/build.py +498 -427
  20. hud/cli/clone.py +185 -185
  21. hud/cli/cursor.py +92 -92
  22. hud/cli/debug.py +392 -392
  23. hud/cli/docker_utils.py +83 -83
  24. hud/cli/init.py +280 -281
  25. hud/cli/interactive.py +353 -353
  26. hud/cli/mcp_server.py +764 -756
  27. hud/cli/pull.py +330 -336
  28. hud/cli/push.py +404 -370
  29. hud/cli/remote_runner.py +311 -311
  30. hud/cli/runner.py +160 -160
  31. hud/cli/tests/__init__.py +3 -3
  32. hud/cli/tests/test_analyze.py +284 -284
  33. hud/cli/tests/test_cli_init.py +265 -265
  34. hud/cli/tests/test_cli_main.py +27 -27
  35. hud/cli/tests/test_clone.py +142 -142
  36. hud/cli/tests/test_cursor.py +253 -253
  37. hud/cli/tests/test_debug.py +453 -453
  38. hud/cli/tests/test_mcp_server.py +139 -139
  39. hud/cli/tests/test_utils.py +388 -388
  40. hud/cli/utils.py +263 -263
  41. hud/clients/README.md +143 -143
  42. hud/clients/__init__.py +16 -16
  43. hud/clients/base.py +378 -379
  44. hud/clients/fastmcp.py +222 -222
  45. hud/clients/mcp_use.py +298 -278
  46. hud/clients/tests/__init__.py +1 -1
  47. hud/clients/tests/test_client_integration.py +111 -111
  48. hud/clients/tests/test_fastmcp.py +342 -342
  49. hud/clients/tests/test_protocol.py +188 -188
  50. hud/clients/utils/__init__.py +1 -1
  51. hud/clients/utils/retry_transport.py +160 -160
  52. hud/datasets.py +327 -322
  53. hud/misc/__init__.py +1 -1
  54. hud/misc/claude_plays_pokemon.py +292 -292
  55. hud/otel/__init__.py +35 -35
  56. hud/otel/collector.py +142 -142
  57. hud/otel/config.py +164 -164
  58. hud/otel/context.py +536 -536
  59. hud/otel/exporters.py +366 -366
  60. hud/otel/instrumentation.py +97 -97
  61. hud/otel/processors.py +118 -118
  62. hud/otel/tests/__init__.py +1 -1
  63. hud/otel/tests/test_processors.py +197 -197
  64. hud/server/__init__.py +5 -5
  65. hud/server/context.py +114 -114
  66. hud/server/helper/__init__.py +5 -5
  67. hud/server/low_level.py +132 -132
  68. hud/server/server.py +170 -166
  69. hud/server/tests/__init__.py +3 -3
  70. hud/settings.py +73 -73
  71. hud/shared/__init__.py +5 -5
  72. hud/shared/exceptions.py +180 -180
  73. hud/shared/requests.py +264 -264
  74. hud/shared/tests/test_exceptions.py +157 -157
  75. hud/shared/tests/test_requests.py +275 -275
  76. hud/telemetry/__init__.py +25 -25
  77. hud/telemetry/instrument.py +379 -379
  78. hud/telemetry/job.py +309 -309
  79. hud/telemetry/replay.py +74 -74
  80. hud/telemetry/trace.py +83 -83
  81. hud/tools/__init__.py +33 -33
  82. hud/tools/base.py +365 -365
  83. hud/tools/bash.py +161 -161
  84. hud/tools/computer/__init__.py +15 -15
  85. hud/tools/computer/anthropic.py +437 -437
  86. hud/tools/computer/hud.py +376 -376
  87. hud/tools/computer/openai.py +295 -295
  88. hud/tools/computer/settings.py +82 -82
  89. hud/tools/edit.py +314 -314
  90. hud/tools/executors/__init__.py +30 -30
  91. hud/tools/executors/base.py +539 -539
  92. hud/tools/executors/pyautogui.py +621 -621
  93. hud/tools/executors/tests/__init__.py +1 -1
  94. hud/tools/executors/tests/test_base_executor.py +338 -338
  95. hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
  96. hud/tools/executors/xdo.py +511 -511
  97. hud/tools/playwright.py +412 -412
  98. hud/tools/tests/__init__.py +3 -3
  99. hud/tools/tests/test_base.py +282 -282
  100. hud/tools/tests/test_bash.py +158 -158
  101. hud/tools/tests/test_bash_extended.py +197 -197
  102. hud/tools/tests/test_computer.py +425 -425
  103. hud/tools/tests/test_computer_actions.py +34 -34
  104. hud/tools/tests/test_edit.py +259 -259
  105. hud/tools/tests/test_init.py +27 -27
  106. hud/tools/tests/test_playwright_tool.py +183 -183
  107. hud/tools/tests/test_tools.py +145 -145
  108. hud/tools/tests/test_utils.py +156 -156
  109. hud/tools/types.py +72 -72
  110. hud/tools/utils.py +50 -50
  111. hud/types.py +136 -136
  112. hud/utils/__init__.py +10 -10
  113. hud/utils/async_utils.py +65 -65
  114. hud/utils/design.py +236 -168
  115. hud/utils/mcp.py +55 -55
  116. hud/utils/progress.py +149 -149
  117. hud/utils/telemetry.py +66 -66
  118. hud/utils/tests/test_async_utils.py +173 -173
  119. hud/utils/tests/test_init.py +17 -17
  120. hud/utils/tests/test_progress.py +261 -261
  121. hud/utils/tests/test_telemetry.py +82 -82
  122. hud/utils/tests/test_version.py +8 -8
  123. hud/version.py +7 -7
  124. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/METADATA +10 -8
  125. hud_python-0.4.3.dist-info/RECORD +131 -0
  126. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/licenses/LICENSE +21 -21
  127. hud/agents/art.py +0 -101
  128. hud_python-0.4.1.dist-info/RECORD +0 -132
  129. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/WHEEL +0 -0
  130. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/entry_points.txt +0 -0
hud/server/low_level.py CHANGED
@@ -1,132 +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
- )
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 CHANGED
@@ -1,166 +1,170 @@
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)
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
+ import contextlib
11
+ from contextlib import asynccontextmanager
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ import anyio
15
+ from fastmcp.server.server import FastMCP, Transport
16
+
17
+ from hud.server.low_level import LowLevelServerWithInit
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import AsyncGenerator, Callable
21
+
22
+ from mcp.shared.context import RequestContext
23
+
24
+ __all__ = ["MCPServer"]
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def _run_with_sigterm(coro_fn: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
30
+ """Run *coro_fn* via anyio.run() and cancel on SIGTERM or SIGINT (POSIX)."""
31
+
32
+ async def _runner() -> None:
33
+ stop_evt: asyncio.Event | None = None
34
+ if sys.platform != "win32" and os.getenv("FASTMCP_DISABLE_SIGTERM_HANDLER") != "1":
35
+ loop = asyncio.get_running_loop()
36
+ stop_evt = asyncio.Event()
37
+
38
+ # Handle both SIGTERM and SIGINT for graceful shutdown
39
+ if signal.getsignal(signal.SIGTERM) is signal.SIG_DFL:
40
+ loop.add_signal_handler(signal.SIGTERM, stop_evt.set)
41
+ if signal.getsignal(signal.SIGINT) is signal.SIG_DFL:
42
+ loop.add_signal_handler(signal.SIGINT, stop_evt.set)
43
+
44
+ async with anyio.create_task_group() as tg:
45
+ tg.start_soon(coro_fn, *args, **kwargs)
46
+
47
+ if stop_evt is not None:
48
+
49
+ async def _watch() -> None:
50
+ logger.info("Waiting for SIGTERM or SIGINT")
51
+ if stop_evt is not None:
52
+ await stop_evt.wait()
53
+ logger.debug("Received shutdown signal, cancelling tasks...")
54
+ tg.cancel_scope.cancel()
55
+
56
+ tg.start_soon(_watch)
57
+
58
+ anyio.run(_runner)
59
+
60
+
61
+ class MCPServer(FastMCP):
62
+ """FastMCP wrapper that adds helpful functionality for dockerized environments.
63
+ This works with any MCP client, and adds just a few extra server-side features:
64
+ 1. SIGTERM handling for graceful shutdown in container runtimes.
65
+ 2. ``@MCPServer.initialize`` decorator that registers an async initializer
66
+ executed during the MCP *initialize* request. The initializer function receives
67
+ a single ``ctx`` parameter (RequestContext) from which you can access:
68
+ - ``ctx.session``: The MCP ServerSession
69
+ - ``ctx.meta.progressToken``: Token for progress notifications (if provided)
70
+ - ``ctx.session.client_params.clientInfo``: Client information
71
+ 3. ``@MCPServer.shutdown`` decorator that registers a coroutine to run during
72
+ server teardown, after all lifespan contexts have exited.
73
+ 4. Enhanced ``add_tool`` that accepts instances of
74
+ :class:`hud.tools.base.BaseTool` which are classes that implement the
75
+ FastMCP ``FunctionTool`` interface.
76
+ """
77
+
78
+ def __init__(self, *, name: str | None = None, **fastmcp_kwargs: Any) -> None:
79
+ # Store shutdown function placeholder before super().__init__
80
+ self._shutdown_fn: Callable | None = None
81
+
82
+ # Inject custom lifespan if user did not supply one
83
+ if "lifespan" not in fastmcp_kwargs:
84
+
85
+ @asynccontextmanager
86
+ async def _lifespan(_: Any) -> AsyncGenerator[dict[str, Any], None]:
87
+ try:
88
+ yield {}
89
+ finally:
90
+ if self._shutdown_fn is not None:
91
+ await self._shutdown_fn()
92
+
93
+ fastmcp_kwargs["lifespan"] = _lifespan
94
+
95
+ super().__init__(name=name, **fastmcp_kwargs)
96
+ self._initializer_fn: Callable | None = None
97
+ self._did_init = False
98
+
99
+ # Replace FastMCP's low-level server with our version that supports
100
+ # per-server initialization hooks
101
+ def _run_init(ctx: RequestContext) -> Any:
102
+ if self._initializer_fn is not None and not self._did_init:
103
+ self._did_init = True
104
+ # Redirect stdout to stderr during initialization to prevent
105
+ # any library prints from corrupting the MCP protocol
106
+ with contextlib.redirect_stdout(sys.stderr):
107
+ return self._initializer_fn(ctx)
108
+ return None
109
+
110
+ # Save the old server's handlers before replacing it
111
+ old_request_handlers = self._mcp_server.request_handlers
112
+ old_notification_handlers = self._mcp_server.notification_handlers
113
+
114
+ self._mcp_server = LowLevelServerWithInit(
115
+ name=self.name,
116
+ version=self.version,
117
+ instructions=self.instructions,
118
+ lifespan=self._mcp_server.lifespan, # reuse the existing lifespan
119
+ init_fn=_run_init,
120
+ )
121
+
122
+ # Copy handlers from the old server to the new one
123
+ self._mcp_server.request_handlers = old_request_handlers
124
+ self._mcp_server.notification_handlers = old_notification_handlers
125
+
126
+ # Initializer decorator: runs on the initialize request
127
+ # The decorated function receives a RequestContext object with access to:
128
+ # - ctx.session: The MCP ServerSession
129
+ # - ctx.meta.progressToken: Progress token (if provided by client)
130
+ # - ctx.session.client_params.clientInfo: Client information
131
+ def initialize(self, fn: Callable | None = None) -> Callable | None:
132
+ def decorator(func: Callable) -> Callable:
133
+ self._initializer_fn = func
134
+ return func
135
+
136
+ return decorator(fn) if fn else decorator
137
+
138
+ # Shutdown decorator: runs after server stops
139
+ # Supports dockerized SIGTERM handling
140
+ def shutdown(self, fn: Callable | None = None) -> Callable | None:
141
+ def decorator(func: Callable) -> Callable:
142
+ self._shutdown_fn = func
143
+ return func
144
+
145
+ return decorator(fn) if fn else decorator
146
+
147
+ # Run with SIGTERM handling and custom initialization
148
+ def run(
149
+ self,
150
+ transport: Transport | None = None,
151
+ show_banner: bool = True,
152
+ **transport_kwargs: Any,
153
+ ) -> None:
154
+ if transport is None:
155
+ transport = "stdio"
156
+
157
+ async def _bootstrap() -> None:
158
+ await self.run_async(transport=transport, show_banner=show_banner, **transport_kwargs) # type: ignore[arg-type]
159
+
160
+ _run_with_sigterm(_bootstrap)
161
+
162
+ # Tool registration helper -- appends BaseTool to FastMCP
163
+ def add_tool(self, obj: Any, **kwargs: Any) -> None:
164
+ from hud.tools.base import BaseTool
165
+
166
+ if isinstance(obj, BaseTool):
167
+ super().add_tool(obj.mcp, **kwargs)
168
+ return
169
+
170
+ super().add_tool(obj, **kwargs)
@@ -1,3 +1,3 @@
1
- from __future__ import annotations
2
-
3
- __all__ = []
1
+ from __future__ import annotations
2
+
3
+ __all__ = []