hud-python 0.4.1__py3-none-any.whl → 0.4.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of hud-python might be problematic. Click here for more details.
- hud/__init__.py +22 -22
- hud/agents/__init__.py +13 -15
- hud/agents/base.py +599 -599
- hud/agents/claude.py +373 -373
- hud/agents/langchain.py +261 -250
- hud/agents/misc/__init__.py +7 -7
- hud/agents/misc/response_agent.py +82 -80
- hud/agents/openai.py +352 -352
- hud/agents/openai_chat_generic.py +154 -154
- hud/agents/tests/__init__.py +1 -1
- hud/agents/tests/test_base.py +742 -742
- hud/agents/tests/test_claude.py +324 -324
- hud/agents/tests/test_client.py +363 -363
- hud/agents/tests/test_openai.py +237 -237
- hud/cli/__init__.py +617 -617
- hud/cli/__main__.py +8 -8
- hud/cli/analyze.py +371 -371
- hud/cli/analyze_metadata.py +230 -230
- hud/cli/build.py +498 -427
- hud/cli/clone.py +185 -185
- hud/cli/cursor.py +92 -92
- hud/cli/debug.py +392 -392
- hud/cli/docker_utils.py +83 -83
- hud/cli/init.py +280 -281
- hud/cli/interactive.py +353 -353
- hud/cli/mcp_server.py +764 -756
- hud/cli/pull.py +330 -336
- hud/cli/push.py +404 -370
- hud/cli/remote_runner.py +311 -311
- hud/cli/runner.py +160 -160
- hud/cli/tests/__init__.py +3 -3
- hud/cli/tests/test_analyze.py +284 -284
- hud/cli/tests/test_cli_init.py +265 -265
- hud/cli/tests/test_cli_main.py +27 -27
- hud/cli/tests/test_clone.py +142 -142
- hud/cli/tests/test_cursor.py +253 -253
- hud/cli/tests/test_debug.py +453 -453
- hud/cli/tests/test_mcp_server.py +139 -139
- hud/cli/tests/test_utils.py +388 -388
- hud/cli/utils.py +263 -263
- hud/clients/README.md +143 -143
- hud/clients/__init__.py +16 -16
- hud/clients/base.py +378 -379
- hud/clients/fastmcp.py +222 -222
- hud/clients/mcp_use.py +298 -278
- hud/clients/tests/__init__.py +1 -1
- hud/clients/tests/test_client_integration.py +111 -111
- hud/clients/tests/test_fastmcp.py +342 -342
- hud/clients/tests/test_protocol.py +188 -188
- hud/clients/utils/__init__.py +1 -1
- hud/clients/utils/retry_transport.py +160 -160
- hud/datasets.py +327 -322
- hud/misc/__init__.py +1 -1
- hud/misc/claude_plays_pokemon.py +292 -292
- hud/otel/__init__.py +35 -35
- hud/otel/collector.py +142 -142
- hud/otel/config.py +164 -164
- hud/otel/context.py +536 -536
- hud/otel/exporters.py +366 -366
- hud/otel/instrumentation.py +97 -97
- hud/otel/processors.py +118 -118
- hud/otel/tests/__init__.py +1 -1
- hud/otel/tests/test_processors.py +197 -197
- hud/server/__init__.py +5 -5
- hud/server/context.py +114 -114
- hud/server/helper/__init__.py +5 -5
- hud/server/low_level.py +132 -132
- hud/server/server.py +170 -166
- hud/server/tests/__init__.py +3 -3
- hud/settings.py +73 -73
- hud/shared/__init__.py +5 -5
- hud/shared/exceptions.py +180 -180
- hud/shared/requests.py +264 -264
- hud/shared/tests/test_exceptions.py +157 -157
- hud/shared/tests/test_requests.py +275 -275
- hud/telemetry/__init__.py +25 -25
- hud/telemetry/instrument.py +379 -379
- hud/telemetry/job.py +309 -309
- hud/telemetry/replay.py +74 -74
- hud/telemetry/trace.py +83 -83
- hud/tools/__init__.py +33 -33
- hud/tools/base.py +365 -365
- hud/tools/bash.py +161 -161
- hud/tools/computer/__init__.py +15 -15
- hud/tools/computer/anthropic.py +437 -437
- hud/tools/computer/hud.py +376 -376
- hud/tools/computer/openai.py +295 -295
- hud/tools/computer/settings.py +82 -82
- hud/tools/edit.py +314 -314
- hud/tools/executors/__init__.py +30 -30
- hud/tools/executors/base.py +539 -539
- hud/tools/executors/pyautogui.py +621 -621
- hud/tools/executors/tests/__init__.py +1 -1
- hud/tools/executors/tests/test_base_executor.py +338 -338
- hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
- hud/tools/executors/xdo.py +511 -511
- hud/tools/playwright.py +412 -412
- hud/tools/tests/__init__.py +3 -3
- hud/tools/tests/test_base.py +282 -282
- hud/tools/tests/test_bash.py +158 -158
- hud/tools/tests/test_bash_extended.py +197 -197
- hud/tools/tests/test_computer.py +425 -425
- hud/tools/tests/test_computer_actions.py +34 -34
- hud/tools/tests/test_edit.py +259 -259
- hud/tools/tests/test_init.py +27 -27
- hud/tools/tests/test_playwright_tool.py +183 -183
- hud/tools/tests/test_tools.py +145 -145
- hud/tools/tests/test_utils.py +156 -156
- hud/tools/types.py +72 -72
- hud/tools/utils.py +50 -50
- hud/types.py +136 -136
- hud/utils/__init__.py +10 -10
- hud/utils/async_utils.py +65 -65
- hud/utils/design.py +236 -168
- hud/utils/mcp.py +55 -55
- hud/utils/progress.py +149 -149
- hud/utils/telemetry.py +66 -66
- hud/utils/tests/test_async_utils.py +173 -173
- hud/utils/tests/test_init.py +17 -17
- hud/utils/tests/test_progress.py +261 -261
- hud/utils/tests/test_telemetry.py +82 -82
- hud/utils/tests/test_version.py +8 -8
- hud/version.py +7 -7
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/METADATA +10 -8
- hud_python-0.4.3.dist-info/RECORD +131 -0
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/licenses/LICENSE +21 -21
- hud/agents/art.py +0 -101
- hud_python-0.4.1.dist-info/RECORD +0 -132
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/WHEEL +0 -0
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/entry_points.txt +0 -0
hud/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
|
-
|
|
11
|
-
from
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
- ``ctx.
|
|
69
|
-
- ``ctx.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
self.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
#
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
#
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
+
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)
|
hud/server/tests/__init__.py
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
__all__ = []
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
__all__ = []
|