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.

Files changed (43) hide show
  1. hud/agents/__init__.py +2 -0
  2. hud/agents/lite_llm.py +72 -0
  3. hud/agents/openai_chat_generic.py +21 -7
  4. hud/cli/__init__.py +19 -4
  5. hud/cli/build.py +17 -2
  6. hud/cli/dev.py +1 -1
  7. hud/cli/eval.py +93 -13
  8. hud/cli/flows/tasks.py +197 -65
  9. hud/cli/push.py +9 -0
  10. hud/cli/rl/__init__.py +14 -4
  11. hud/cli/rl/celebrate.py +187 -0
  12. hud/cli/rl/config.py +15 -8
  13. hud/cli/rl/local_runner.py +44 -20
  14. hud/cli/rl/remote_runner.py +163 -86
  15. hud/cli/rl/viewer.py +141 -0
  16. hud/cli/rl/wait_utils.py +89 -0
  17. hud/cli/utils/env_check.py +196 -0
  18. hud/cli/utils/source_hash.py +108 -0
  19. hud/clients/base.py +1 -1
  20. hud/clients/fastmcp.py +1 -1
  21. hud/otel/config.py +1 -1
  22. hud/otel/context.py +2 -2
  23. hud/rl/vllm_adapter.py +1 -1
  24. hud/server/server.py +84 -13
  25. hud/server/tests/test_add_tool.py +60 -0
  26. hud/server/tests/test_context.py +128 -0
  27. hud/server/tests/test_mcp_server_handlers.py +44 -0
  28. hud/server/tests/test_mcp_server_integration.py +405 -0
  29. hud/server/tests/test_mcp_server_more.py +247 -0
  30. hud/server/tests/test_run_wrapper.py +53 -0
  31. hud/server/tests/test_server_extra.py +166 -0
  32. hud/server/tests/test_sigterm_runner.py +78 -0
  33. hud/shared/hints.py +1 -1
  34. hud/telemetry/job.py +2 -2
  35. hud/types.py +9 -2
  36. hud/utils/tasks.py +32 -24
  37. hud/utils/tests/test_version.py +1 -1
  38. hud/version.py +1 -1
  39. {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/METADATA +14 -12
  40. {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/RECORD +43 -29
  41. {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/WHEEL +0 -0
  42. {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/entry_points.txt +0 -0
  43. {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