hud-python 0.4.36__py3-none-any.whl → 0.4.37__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/agents/__init__.py +2 -0
- hud/agents/lite_llm.py +72 -0
- hud/agents/openai_chat_generic.py +21 -7
- hud/cli/__init__.py +19 -4
- hud/cli/build.py +17 -2
- hud/cli/dev.py +1 -1
- hud/cli/eval.py +93 -13
- hud/cli/flows/tasks.py +197 -65
- hud/cli/push.py +9 -0
- hud/cli/rl/__init__.py +14 -4
- hud/cli/rl/celebrate.py +187 -0
- hud/cli/rl/config.py +15 -8
- hud/cli/rl/local_runner.py +44 -20
- hud/cli/rl/remote_runner.py +163 -86
- hud/cli/rl/viewer.py +141 -0
- hud/cli/rl/wait_utils.py +89 -0
- hud/cli/utils/env_check.py +196 -0
- hud/cli/utils/source_hash.py +108 -0
- hud/clients/base.py +1 -1
- hud/clients/fastmcp.py +1 -1
- hud/otel/config.py +1 -1
- hud/otel/context.py +2 -2
- hud/rl/vllm_adapter.py +1 -1
- hud/server/server.py +84 -13
- hud/server/tests/test_add_tool.py +60 -0
- hud/server/tests/test_context.py +128 -0
- hud/server/tests/test_mcp_server_handlers.py +44 -0
- hud/server/tests/test_mcp_server_integration.py +405 -0
- hud/server/tests/test_mcp_server_more.py +247 -0
- hud/server/tests/test_run_wrapper.py +53 -0
- hud/server/tests/test_server_extra.py +166 -0
- hud/server/tests/test_sigterm_runner.py +78 -0
- hud/shared/hints.py +1 -1
- hud/telemetry/job.py +2 -2
- hud/types.py +9 -2
- hud/utils/tasks.py +32 -24
- hud/utils/tests/test_version.py +1 -1
- hud/version.py +1 -1
- {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/METADATA +14 -12
- {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/RECORD +43 -29
- {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/WHEEL +0 -0
- {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import multiprocessing.connection as _mp_conn
|
|
8
|
+
|
|
9
|
+
# Pull the exception dynamically; fall back to OSError if missing in stubs/runtime
|
|
10
|
+
MPAuthenticationError: type[BaseException] = getattr(_mp_conn, "AuthenticationError", OSError)
|
|
11
|
+
except Exception: # pragma: no cover
|
|
12
|
+
MPAuthenticationError = OSError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from hud.server.context import attach_context, serve_context
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
pytestmark = pytest.mark.skipif(
|
|
25
|
+
sys.platform == "win32",
|
|
26
|
+
reason="Context server uses UNIX domain sockets",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CounterCtx:
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self._n = 0
|
|
33
|
+
|
|
34
|
+
def inc(self) -> int:
|
|
35
|
+
self._n += 1
|
|
36
|
+
return self._n
|
|
37
|
+
|
|
38
|
+
def get(self) -> int:
|
|
39
|
+
return self._n
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_serve_and_attach_shared_state(tmp_path: Path) -> None:
|
|
43
|
+
sock = str(tmp_path / "hud_ctx.sock")
|
|
44
|
+
|
|
45
|
+
mgr = serve_context(CounterCtx(), sock_path=sock)
|
|
46
|
+
try:
|
|
47
|
+
c1 = attach_context(sock_path=sock)
|
|
48
|
+
assert c1.get() == 0
|
|
49
|
+
assert c1.inc() == 1
|
|
50
|
+
|
|
51
|
+
# Second attachment sees the same underlying object
|
|
52
|
+
c2 = attach_context(sock_path=sock)
|
|
53
|
+
assert c2.get() == 1
|
|
54
|
+
assert c2.inc() == 2
|
|
55
|
+
assert c1.get() == 2 # shared state
|
|
56
|
+
finally:
|
|
57
|
+
mgr.shutdown()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_env_var_socket_path_overrides(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
61
|
+
sock = str(tmp_path / "env_ctx.sock")
|
|
62
|
+
monkeypatch.setenv("HUD_CTX_SOCK", sock)
|
|
63
|
+
|
|
64
|
+
mgr = serve_context(CounterCtx(), sock_path=None)
|
|
65
|
+
try:
|
|
66
|
+
c = attach_context(sock_path=None)
|
|
67
|
+
assert c.inc() == 1
|
|
68
|
+
assert c.get() == 1
|
|
69
|
+
finally:
|
|
70
|
+
mgr.shutdown()
|
|
71
|
+
monkeypatch.delenv("HUD_CTX_SOCK", raising=False)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_wrong_authkey_rejected(tmp_path: Path) -> None:
|
|
75
|
+
sock = str(tmp_path / "auth_ctx.sock")
|
|
76
|
+
mgr = serve_context(CounterCtx(), sock_path=sock, authkey=b"correct")
|
|
77
|
+
try:
|
|
78
|
+
with pytest.raises(
|
|
79
|
+
(MPAuthenticationError, ConnectionRefusedError, BrokenPipeError, OSError)
|
|
80
|
+
):
|
|
81
|
+
attach_context(sock_path=sock, authkey=b"wrong")
|
|
82
|
+
finally:
|
|
83
|
+
mgr.shutdown()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_attach_nonexistent_raises(tmp_path: Path) -> None:
|
|
87
|
+
# ensure file truly doesn't exist
|
|
88
|
+
sock = str(tmp_path / "missing.sock")
|
|
89
|
+
if os.path.exists(sock):
|
|
90
|
+
os.unlink(sock)
|
|
91
|
+
|
|
92
|
+
with pytest.raises((FileNotFoundError, ConnectionRefusedError, OSError)):
|
|
93
|
+
attach_context(sock_path=sock)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.asyncio
|
|
97
|
+
async def test_run_context_server_handles_keyboardinterrupt(
|
|
98
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
99
|
+
) -> None:
|
|
100
|
+
"""run_context_server should call manager.shutdown() when KeyboardInterrupt occurs."""
|
|
101
|
+
# Capture serve_context() and the returned manager
|
|
102
|
+
called = {"served": False, "shutdown": False, "addr": None}
|
|
103
|
+
|
|
104
|
+
class _Mgr:
|
|
105
|
+
def shutdown(self) -> None:
|
|
106
|
+
called["shutdown"] = True
|
|
107
|
+
|
|
108
|
+
def fake_serve(ctx, sock_path, authkey):
|
|
109
|
+
called["served"] = True
|
|
110
|
+
called["addr"] = sock_path
|
|
111
|
+
return _Mgr()
|
|
112
|
+
|
|
113
|
+
monkeypatch.setattr("hud.server.context.serve_context", fake_serve)
|
|
114
|
+
|
|
115
|
+
# Make asyncio.Event().wait() raise KeyboardInterrupt immediately
|
|
116
|
+
class _FakeEvent:
|
|
117
|
+
async def wait(self) -> None:
|
|
118
|
+
raise KeyboardInterrupt
|
|
119
|
+
|
|
120
|
+
monkeypatch.setattr("hud.server.context.asyncio.Event", lambda: _FakeEvent())
|
|
121
|
+
|
|
122
|
+
from hud.server.context import run_context_server
|
|
123
|
+
|
|
124
|
+
await run_context_server(object(), sock_path=str(tmp_path / "ctx.sock"))
|
|
125
|
+
|
|
126
|
+
assert called["served"] is True
|
|
127
|
+
assert called["shutdown"] is True
|
|
128
|
+
assert str(called["addr"]).endswith("ctx.sock")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
from hud.server import MCPServer
|
|
6
|
+
from hud.server.low_level import LowLevelServerWithInit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_notification_handlers_preserved_on_replacement():
|
|
10
|
+
"""When init server replaces low-level server, notification handlers must be kept."""
|
|
11
|
+
mcp = MCPServer(name="PreserveNotif")
|
|
12
|
+
|
|
13
|
+
# Seed a fake notification handler on the pre-replacement server
|
|
14
|
+
before = mcp._mcp_server
|
|
15
|
+
cast("dict[Any, Any]", before.notification_handlers)["foo/notify"] = object()
|
|
16
|
+
|
|
17
|
+
@mcp.initialize
|
|
18
|
+
async def _init(_ctx) -> None:
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
after = mcp._mcp_server
|
|
22
|
+
assert isinstance(after, LowLevelServerWithInit)
|
|
23
|
+
assert after is not before, "low-level server should be replaced once"
|
|
24
|
+
# Must still contain our seeded handler (dict is copied over)
|
|
25
|
+
assert "foo/notify" in after.notification_handlers
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_init_server_replacement_is_idempotent():
|
|
29
|
+
"""Second @initialize must NOT replace the low-level server again."""
|
|
30
|
+
mcp = MCPServer(name="InitIdempotent")
|
|
31
|
+
|
|
32
|
+
@mcp.initialize
|
|
33
|
+
async def _a(_ctx) -> None:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
first = mcp._mcp_server
|
|
37
|
+
|
|
38
|
+
@mcp.initialize
|
|
39
|
+
async def _b(_ctx) -> None:
|
|
40
|
+
# last initializer should win, but server object should not be replaced again
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
second = mcp._mcp_server
|
|
44
|
+
assert first is second, "Server replacement should occur at most once per instance"
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import socket
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from hud.clients import MCPClient
|
|
11
|
+
from hud.server import MCPServer
|
|
12
|
+
from hud.server import server as server_mod # for toggling _sigterm_received
|
|
13
|
+
from hud.server.low_level import LowLevelServerWithInit
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _free_port() -> int:
|
|
17
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
18
|
+
s.bind(("127.0.0.1", 0))
|
|
19
|
+
return s.getsockname()[1]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _start_http_server(mcp: MCPServer, port: int) -> asyncio.Task:
|
|
23
|
+
# run the server in the background; cancel to stop
|
|
24
|
+
task = asyncio.create_task(
|
|
25
|
+
mcp.run_async(
|
|
26
|
+
transport="http",
|
|
27
|
+
host="127.0.0.1",
|
|
28
|
+
port=port,
|
|
29
|
+
path="/mcp",
|
|
30
|
+
log_level="ERROR",
|
|
31
|
+
show_banner=False,
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
# brief yield so uvicorn can boot
|
|
35
|
+
await asyncio.sleep(0.05)
|
|
36
|
+
return task
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _first_text(result) -> str | None:
|
|
40
|
+
# Result.content is usually a list of TextContent
|
|
41
|
+
c = getattr(result, "content", None)
|
|
42
|
+
if isinstance(c, list) and c and hasattr(c[0], "text"):
|
|
43
|
+
return c[0].text
|
|
44
|
+
if isinstance(c, str):
|
|
45
|
+
return c
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.asyncio
|
|
50
|
+
async def test_low_level_injection_happens_when_initialize_used() -> None:
|
|
51
|
+
mcp = MCPServer(name="InitInject")
|
|
52
|
+
assert not isinstance(mcp._mcp_server, LowLevelServerWithInit)
|
|
53
|
+
|
|
54
|
+
@mcp.initialize
|
|
55
|
+
async def _init(_ctx) -> None:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
assert isinstance(mcp._mcp_server, LowLevelServerWithInit)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.asyncio
|
|
62
|
+
async def test_initialize_runs_once_and_tools_work() -> None:
|
|
63
|
+
port = _free_port()
|
|
64
|
+
|
|
65
|
+
mcp = MCPServer(name="ServerInitOnce")
|
|
66
|
+
state = {"init_calls": 0, "initialized": False}
|
|
67
|
+
|
|
68
|
+
@mcp.initialize
|
|
69
|
+
async def _init(_ctx) -> None:
|
|
70
|
+
# this would corrupt stdout if not redirected; we rely on stderr redirection
|
|
71
|
+
print("hello from init") # noqa: T201
|
|
72
|
+
state["init_calls"] += 1
|
|
73
|
+
state["initialized"] = True
|
|
74
|
+
|
|
75
|
+
@mcp.tool()
|
|
76
|
+
async def initialized() -> bool: # type: ignore[override]
|
|
77
|
+
return state["initialized"]
|
|
78
|
+
|
|
79
|
+
@mcp.tool()
|
|
80
|
+
async def echo(text: str = "ok") -> str: # type: ignore[override]
|
|
81
|
+
return f"echo:{text}"
|
|
82
|
+
|
|
83
|
+
server_task = await _start_http_server(mcp, port)
|
|
84
|
+
|
|
85
|
+
async def connect_and_check() -> None:
|
|
86
|
+
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
87
|
+
client = MCPClient(mcp_config=cfg, auto_trace=False, verbose=False)
|
|
88
|
+
await client.initialize()
|
|
89
|
+
tools = await client.list_tools()
|
|
90
|
+
names = sorted(t.name for t in tools)
|
|
91
|
+
assert {"echo", "initialized"} <= set(names)
|
|
92
|
+
res = await client.call_tool(name="initialized", arguments={})
|
|
93
|
+
# boolean return is exposed via structuredContent["result"]
|
|
94
|
+
assert getattr(res, "structuredContent", {}).get("result") is True
|
|
95
|
+
res2 = await client.call_tool(name="echo", arguments={"text": "ping"})
|
|
96
|
+
assert _first_text(res2) == "echo:ping"
|
|
97
|
+
await client.shutdown()
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
await connect_and_check()
|
|
101
|
+
await connect_and_check()
|
|
102
|
+
# initializer should have executed only once across multiple clients
|
|
103
|
+
assert state["init_calls"] == 1
|
|
104
|
+
finally:
|
|
105
|
+
with suppress(asyncio.CancelledError):
|
|
106
|
+
server_task.cancel()
|
|
107
|
+
await server_task
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.asyncio
|
|
111
|
+
async def test_shutdown_handler_only_on_sigterm_flag() -> None:
|
|
112
|
+
port = _free_port()
|
|
113
|
+
|
|
114
|
+
mcp = MCPServer(name="ShutdownTest")
|
|
115
|
+
called = asyncio.Event()
|
|
116
|
+
|
|
117
|
+
@mcp.shutdown
|
|
118
|
+
async def _on_shutdown() -> None:
|
|
119
|
+
called.set()
|
|
120
|
+
|
|
121
|
+
# no SIGTERM flag: should NOT call shutdown on normal cancel
|
|
122
|
+
server_task = await _start_http_server(mcp, port)
|
|
123
|
+
try:
|
|
124
|
+
# sanity connect so lifespan actually ran
|
|
125
|
+
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
126
|
+
c = MCPClient(mcp_config=cfg, auto_trace=False, verbose=False)
|
|
127
|
+
await c.initialize()
|
|
128
|
+
await c.shutdown()
|
|
129
|
+
finally:
|
|
130
|
+
with suppress(asyncio.CancelledError):
|
|
131
|
+
server_task.cancel()
|
|
132
|
+
await server_task
|
|
133
|
+
# give a tick to let lifespan finally run
|
|
134
|
+
await asyncio.sleep(0.05)
|
|
135
|
+
assert not called.is_set()
|
|
136
|
+
|
|
137
|
+
# now start again and simulate SIGTERM so shutdown handler fires
|
|
138
|
+
called.clear()
|
|
139
|
+
port2 = _free_port()
|
|
140
|
+
server_task2 = await _start_http_server(mcp, port=port2)
|
|
141
|
+
try:
|
|
142
|
+
cfg = {"srv": {"url": f"http://127.0.0.1:{port2}/mcp"}}
|
|
143
|
+
c = MCPClient(mcp_config=cfg, auto_trace=False, verbose=False)
|
|
144
|
+
await c.initialize()
|
|
145
|
+
await c.shutdown()
|
|
146
|
+
|
|
147
|
+
# flip the module-level flag the lifespan checks
|
|
148
|
+
server_mod._sigterm_received = True # type: ignore[attr-defined]
|
|
149
|
+
finally:
|
|
150
|
+
with suppress(asyncio.CancelledError):
|
|
151
|
+
server_task2.cancel()
|
|
152
|
+
await server_task2
|
|
153
|
+
|
|
154
|
+
# shutdown coroutine should have run because flag was set when lifespan exited
|
|
155
|
+
assert called.is_set()
|
|
156
|
+
# reset the flag for any other tests
|
|
157
|
+
server_mod._sigterm_received = False # type: ignore[attr-defined]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@pytest.mark.asyncio
|
|
161
|
+
async def test_initializer_exception_propagates_to_client() -> None:
|
|
162
|
+
port = _free_port()
|
|
163
|
+
|
|
164
|
+
mcp = MCPServer(name="InitError")
|
|
165
|
+
|
|
166
|
+
@mcp.initialize
|
|
167
|
+
async def _init(_ctx) -> None:
|
|
168
|
+
raise RuntimeError("boom during init")
|
|
169
|
+
|
|
170
|
+
server_task = await _start_http_server(mcp, port)
|
|
171
|
+
|
|
172
|
+
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
173
|
+
client = MCPClient(mcp_config=cfg, auto_trace=False, verbose=False)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
with pytest.raises(Exception):
|
|
177
|
+
await client.initialize()
|
|
178
|
+
finally:
|
|
179
|
+
with suppress(asyncio.CancelledError):
|
|
180
|
+
server_task.cancel()
|
|
181
|
+
await server_task
|
|
182
|
+
# defensive: client may or may not be fully created
|
|
183
|
+
with suppress(Exception):
|
|
184
|
+
await client.shutdown()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# --- additional tests for MCPServer coverage ---
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@pytest.mark.asyncio
|
|
191
|
+
async def test_init_after_tools_preserves_handlers_and_runs_once() -> None:
|
|
192
|
+
"""If tools are added BEFORE @mcp.initialize, the handler copy during
|
|
193
|
+
low-level server replacement must keep them; init should still run once total.
|
|
194
|
+
"""
|
|
195
|
+
port = _free_port()
|
|
196
|
+
|
|
197
|
+
mcp = MCPServer(name="InitAfterTools")
|
|
198
|
+
state = {"init_calls": 0}
|
|
199
|
+
|
|
200
|
+
# Register tools first
|
|
201
|
+
@mcp.tool()
|
|
202
|
+
async def foo() -> str: # type: ignore[override]
|
|
203
|
+
return "bar"
|
|
204
|
+
|
|
205
|
+
# Now register initializer (this triggers server replacement)
|
|
206
|
+
@mcp.initialize
|
|
207
|
+
async def _init(_ctx) -> None:
|
|
208
|
+
state["init_calls"] += 1
|
|
209
|
+
|
|
210
|
+
server_task = await _start_http_server(mcp, port)
|
|
211
|
+
|
|
212
|
+
async def connect_and_check() -> None:
|
|
213
|
+
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
214
|
+
c = MCPClient(mcp_config=cfg, auto_trace=False, verbose=False)
|
|
215
|
+
await c.initialize()
|
|
216
|
+
tools = await c.list_tools()
|
|
217
|
+
names = sorted(t.name for t in tools)
|
|
218
|
+
assert "foo" in names, "tool registered before @initialize must survive replacement"
|
|
219
|
+
res = await c.call_tool(name="foo", arguments={})
|
|
220
|
+
assert _first_text(res) == "bar"
|
|
221
|
+
await c.shutdown()
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
await connect_and_check()
|
|
225
|
+
await connect_and_check()
|
|
226
|
+
assert state["init_calls"] == 1, "initializer should execute exactly once"
|
|
227
|
+
finally:
|
|
228
|
+
with suppress(asyncio.CancelledError):
|
|
229
|
+
server_task.cancel()
|
|
230
|
+
await server_task
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@pytest.mark.asyncio
|
|
234
|
+
async def test_tool_default_argument_used_when_omitted() -> None:
|
|
235
|
+
"""Echo tool should use its default when argument is omitted."""
|
|
236
|
+
port = _free_port()
|
|
237
|
+
|
|
238
|
+
mcp = MCPServer(name="EchoDefault")
|
|
239
|
+
|
|
240
|
+
@mcp.tool()
|
|
241
|
+
async def echo(text: str = "ok") -> str: # type: ignore[override]
|
|
242
|
+
return f"echo:{text}"
|
|
243
|
+
|
|
244
|
+
server_task = await _start_http_server(mcp, port)
|
|
245
|
+
try:
|
|
246
|
+
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
247
|
+
c = MCPClient(mcp_config=cfg, auto_trace=False, verbose=False)
|
|
248
|
+
await c.initialize()
|
|
249
|
+
# Call with no args → default should kick in
|
|
250
|
+
res = await c.call_tool(name="echo", arguments={})
|
|
251
|
+
assert _first_text(res) == "echo:ok"
|
|
252
|
+
await c.shutdown()
|
|
253
|
+
finally:
|
|
254
|
+
with suppress(asyncio.CancelledError):
|
|
255
|
+
server_task.cancel()
|
|
256
|
+
await server_task
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@pytest.mark.asyncio
|
|
260
|
+
async def test_shutdown_handler_runs_once_when_both_paths_fire() -> None:
|
|
261
|
+
"""With SIGTERM flag set, both the lifespan.finally and run_async.finally would
|
|
262
|
+
try to invoke @mcp.shutdown. The per-instance guard must ensure exactly once.
|
|
263
|
+
"""
|
|
264
|
+
port = _free_port()
|
|
265
|
+
mcp = MCPServer(name="ShutdownOnce")
|
|
266
|
+
calls = {"n": 0}
|
|
267
|
+
|
|
268
|
+
@mcp.shutdown
|
|
269
|
+
async def _on_shutdown() -> None:
|
|
270
|
+
calls["n"] += 1
|
|
271
|
+
|
|
272
|
+
server_task = await _start_http_server(mcp, port)
|
|
273
|
+
try:
|
|
274
|
+
# Ensure lifespan started
|
|
275
|
+
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
276
|
+
c = MCPClient(mcp_config=cfg, auto_trace=False, verbose=False)
|
|
277
|
+
await c.initialize()
|
|
278
|
+
await c.shutdown()
|
|
279
|
+
|
|
280
|
+
# Arm SIGTERM flag so both code paths believe they should run
|
|
281
|
+
server_mod._sigterm_received = True # type: ignore[attr-defined]
|
|
282
|
+
finally:
|
|
283
|
+
with suppress(asyncio.CancelledError):
|
|
284
|
+
server_task.cancel()
|
|
285
|
+
await server_task
|
|
286
|
+
|
|
287
|
+
# Give the event loop a tick to run both finalizers
|
|
288
|
+
await asyncio.sleep(0.05)
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
assert calls["n"] == 1, f"shutdown hook must run exactly once, got {calls['n']}"
|
|
292
|
+
finally:
|
|
293
|
+
# Always reset module flag
|
|
294
|
+
server_mod._sigterm_received = False # type: ignore[attr-defined]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@pytest.mark.asyncio
|
|
298
|
+
async def test_initialize_ctx_exposes_client_info() -> None:
|
|
299
|
+
"""Initializer gets a ctx; clientInfo may be absent depending on client implementation."""
|
|
300
|
+
port = _free_port()
|
|
301
|
+
|
|
302
|
+
mcp = MCPServer(name="InitCtx")
|
|
303
|
+
seen = {"has_session": False, "client_name": None}
|
|
304
|
+
|
|
305
|
+
@mcp.initialize
|
|
306
|
+
async def _init(ctx) -> None: # type: ignore[override]
|
|
307
|
+
# Ensure we have a session object
|
|
308
|
+
seen["has_session"] = hasattr(ctx, "session") and ctx.session is not None
|
|
309
|
+
|
|
310
|
+
# Client info is optional; capture it if present
|
|
311
|
+
client_info = getattr(getattr(ctx.session, "client_params", None), "clientInfo", None)
|
|
312
|
+
if client_info is not None:
|
|
313
|
+
seen["client_name"] = getattr(client_info, "name", None)
|
|
314
|
+
|
|
315
|
+
server_task = await _start_http_server(mcp, port)
|
|
316
|
+
try:
|
|
317
|
+
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
318
|
+
c = MCPClient(mcp_config=cfg, auto_trace=False, verbose=False)
|
|
319
|
+
await c.initialize()
|
|
320
|
+
await c.shutdown()
|
|
321
|
+
finally:
|
|
322
|
+
with suppress(asyncio.CancelledError):
|
|
323
|
+
server_task.cancel()
|
|
324
|
+
await server_task
|
|
325
|
+
|
|
326
|
+
assert seen["has_session"] is True
|
|
327
|
+
# If present, name should be a string; otherwise None is acceptable.
|
|
328
|
+
assert seen["client_name"] is None or isinstance(seen["client_name"], str)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@pytest.mark.asyncio
|
|
332
|
+
async def test_initialize_redirects_stdout_to_stderr(capsys) -> None:
|
|
333
|
+
"""Initializer prints should be redirected to stderr (never stdout)."""
|
|
334
|
+
port = _free_port()
|
|
335
|
+
|
|
336
|
+
mcp = MCPServer(name="StdoutRedirect")
|
|
337
|
+
|
|
338
|
+
@mcp.initialize
|
|
339
|
+
async def _init(_ctx) -> None:
|
|
340
|
+
# This would normally pollute STDOUT; our server redirects to STDERR
|
|
341
|
+
print("INIT_STDOUT_MARKER") # noqa: T201
|
|
342
|
+
|
|
343
|
+
server_task = await _start_http_server(mcp, port)
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
347
|
+
c = MCPClient(mcp_config=cfg, auto_trace=False, verbose=False)
|
|
348
|
+
await c.initialize()
|
|
349
|
+
await c.shutdown()
|
|
350
|
+
finally:
|
|
351
|
+
with suppress(asyncio.CancelledError):
|
|
352
|
+
server_task.cancel()
|
|
353
|
+
await server_task
|
|
354
|
+
|
|
355
|
+
captured = capsys.readouterr()
|
|
356
|
+
assert "INIT_STDOUT_MARKER" in captured.err
|
|
357
|
+
assert "INIT_STDOUT_MARKER" not in captured.out
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@pytest.mark.asyncio
|
|
361
|
+
async def test_initialize_callable_form_runs_once() -> None:
|
|
362
|
+
"""Coverage for mcp.initialize(fn) (callable style), not only decorator usage."""
|
|
363
|
+
port = _free_port()
|
|
364
|
+
mcp = MCPServer(name="CallableInit")
|
|
365
|
+
hits = {"n": 0}
|
|
366
|
+
|
|
367
|
+
async def _init(_ctx) -> None:
|
|
368
|
+
hits["n"] += 1
|
|
369
|
+
|
|
370
|
+
# Callable form instead of decorator
|
|
371
|
+
mcp.initialize(_init)
|
|
372
|
+
|
|
373
|
+
server_task = await _start_http_server(mcp, port)
|
|
374
|
+
try:
|
|
375
|
+
cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
|
|
376
|
+
c1 = MCPClient(mcp_config=cfg, auto_trace=False, verbose=False)
|
|
377
|
+
await c1.initialize()
|
|
378
|
+
await c1.shutdown()
|
|
379
|
+
|
|
380
|
+
c2 = MCPClient(mcp_config=cfg, auto_trace=False, verbose=False)
|
|
381
|
+
await c2.initialize()
|
|
382
|
+
await c2.shutdown()
|
|
383
|
+
finally:
|
|
384
|
+
with suppress(asyncio.CancelledError):
|
|
385
|
+
server_task.cancel()
|
|
386
|
+
await server_task
|
|
387
|
+
|
|
388
|
+
assert hits["n"] == 1
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@pytest.mark.asyncio
|
|
392
|
+
async def test_notification_handlers_survive_real_replacement() -> None:
|
|
393
|
+
"""End-to-end check that notification handlers survive when initialize is registered."""
|
|
394
|
+
mcp = MCPServer(name="NotifCopy")
|
|
395
|
+
|
|
396
|
+
# Seed a dummy notification handler before replacement
|
|
397
|
+
cast("dict[Any, Any]", mcp._mcp_server.notification_handlers)["hud/notify"] = object()
|
|
398
|
+
assert "hud/notify" in mcp._mcp_server.notification_handlers
|
|
399
|
+
|
|
400
|
+
@mcp.initialize
|
|
401
|
+
async def _init(_ctx) -> None:
|
|
402
|
+
pass
|
|
403
|
+
|
|
404
|
+
# After replacement, the handler should still be there
|
|
405
|
+
assert "hud/notify" in mcp._mcp_server.notification_handlers
|