Files
turnstone/tests/test_mcp_live_push_refresh.py
T
Patrick Buckley 1a80466369 fix(mcp): review round 4 — removal/reconcile lifecycle, honest skip reporting, same-kind debounce recovery
reconcile_sync no longer abandons a DB-driven removal that timed out:
both the removal loop and the config-update loop keep the name in
_db_managed (and skip the follow-on add) when remove_server_sync
returns its mutated-nothing False, so the next pass retries instead of
the deleted/reconfigured server serving stale tools until restart.

remove_server_sync is now cancel-safe end to end: it FORCE-drops the
session before queueing (parked push runners bail at their session
gate instead of serializing ≤30s list calls ahead of the removal —
the noisy #839 server was exactly the one whose runners could starve
its own removal), and wraps the post-lock cleanup in try/finally so a
caller-timeout cancel landing mid-teardown still completes the state
pop, catalog rebuild, and lock retirement rather than stranding a
config-gone ghost catalog. Config survives a park-cancel, so the
health loop recovers it.

_refresh_all reports None (not a fake ([], [])) for a busy-skip or
supersede, stamps a 'skipped' status row, and /mcp refresh renders it
distinctly — the operator is no longer told a never-refreshed server
is current. A same-kind push lost to the debounce window (the prior
runner already finished; the server won't re-announce) arms the
health-tick retry, closing the one staleness hole the per-kind
debounce still had; a push covered by a queued runner does not arm
(no lost change). Static resource/prompt catalogs are capped at
connect discovery and every refresh. _list_resource_pair's reap is
bounded so a future SDK cancel-regression can't wedge the lock.

Cleanups: _arm_refresh_retry (retry-arm gate, ×3), _spawn_full_refresh
(discard+spawn, ×3), _popen_mcp_server (live-server spawn, ×2), the
tautological stamp-arithmetic TestNotificationDebounce deleted. Suite
9395 green.

Refs #839
2026-07-14 11:39:25 -07:00

162 lines
6.1 KiB
Python

"""Live push-refresh smoke test: a real ``tools/list_changed`` lands, no wedge.
End-to-end regression for #839: the static-path notification handler used to
await its catalog refresh inline in the SDK's receive loop, but the refresh
issues a request on the SAME session — a request whose response only that
(now parked) receive loop could route. The refresh never completed, the
receive loop wedged permanently, and every call on the shared per-node
session stalled behind it; the only "recovery" was the health loop's ping
timeout tearing the transport down.
A real streamable-http MCP server (FastMCP, subprocess) registers an extra
tool at runtime inside a tool call and pushes ``notifications/tools/
list_changed`` on the live session — so the notification and the call result
are multiplexed on the real SDK receive loop, exactly the production shape.
Pass criteria are discriminating on purpose:
* the TRIGGERING ``call_tool`` returns promptly — on the pre-fix code its
result could never route past the parked receive loop, so this call is
itself the deadlock repro;
* the pushed catalog change lands on the SAME session object (health loop
slowed to keep teardown/reconnect out of the picture) — the push did the
work, not a rebuild;
* the newly pushed tool DISPATCHES — the merged tool map was rebuilt, and
the receive loop is still routing responses afterwards.
Self-contained (spawns its own server; no LLM backend, no network beyond
127.0.0.1) — deliberately NOT marked ``live``, mirroring
``test_mcp_live_flaky_server.py``. Wall clock ~5s.
"""
from __future__ import annotations
import textwrap
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from tests.conftest import (
_free_port,
_poll_until,
_popen_mcp_server,
_wait_session_live,
_wait_tcp_ready,
)
from turnstone.core.mcp_client import MCPClientManager
if TYPE_CHECKING:
import subprocess
from pathlib import Path
SERVER_SRC = textwrap.dedent(
'''
"""Streamable-http MCP server that grows a tool at runtime (#839 repro)."""
import sys
from mcp.server.fastmcp import Context, FastMCP
port = int(sys.argv[1])
mcp = FastMCP("push-victim", host="127.0.0.1", port=port)
@mcp.tool()
def ping_me(x: int) -> int:
"""Return x + 1."""
return x + 1
def extra_tool(y: int) -> int:
"""Return y * 2."""
return y * 2
@mcp.tool()
async def register_extra(ctx: Context) -> str:
"""Register extra_tool, then push tools/list_changed on this session."""
mcp.add_tool(extra_tool)
await ctx.session.send_tool_list_changed()
return "registered"
if __name__ == "__main__":
mcp.run(transport="streamable-http")
'''
).lstrip()
def _wait_tool_visible(mgr: MCPClientManager, server: str, tool: str, timeout: float) -> bool:
"""Poll the per-server catalog for *tool* — the push refresh landing."""
def _visible() -> bool:
state = mgr._static_servers.get(server)
return state is not None and any(t["function"]["name"] == tool for t in state.tools)
return _poll_until(_visible, timeout)
class TestPushRefreshNoDeadlock:
def test_list_changed_push_refreshes_without_teardown(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# The subprocess runs sys.executable, so importability HERE is a
# faithful proxy for the server side. Environment gaps skip, not fail.
pytest.importorskip("mcp.server.fastmcp")
script = tmp_path / "push_srv.py"
script.write_text(SERVER_SRC)
port = _free_port()
# Bound connect/discovery/refresh phases for a unit-test budget, but
# SLOW the health loop right down: pre-fix, its ping-timeout teardown
# was the accidental recovery path, and this test must prove the push
# itself does the work on the ORIGINAL session.
monkeypatch.setattr(MCPClientManager, "_CONNECT_TIMEOUT", 5)
monkeypatch.setattr(MCPClientManager, "_TCP_PROBE_TIMEOUT", 1)
proc: subprocess.Popen[bytes] | None = None
mgr: MCPClientManager | None = None
try:
proc = _popen_mcp_server(script, port)
if not _wait_tcp_ready(port, 10.0):
pytest.skip("push-refresh server subprocess did not come up")
with patch(
"turnstone.core.mcp_client.load_config",
return_value={"static_health_check_seconds": 30},
):
mgr = MCPClientManager(
{"push": {"type": "http", "url": f"http://127.0.0.1:{port}/mcp"}}
)
mgr.start()
assert _wait_session_live(mgr, "push", 8.0), "initial connect failed"
state = mgr._static_servers["push"]
session_before = state.session
assert not any(t["function"]["name"] == "mcp__push__extra_tool" for t in state.tools), (
"extra_tool must not exist before the push"
)
# THE repro: the server pushes list_changed while this call is in
# flight, so its result and the notification share the receive
# loop. Pre-fix, the inline-await handler parked that loop and
# this call never returned.
out = mgr.call_tool_sync("mcp__push__register_extra", {}, timeout=10)
assert "registered" in out
# The push-driven refresh completes on its own — no teardown.
assert _wait_tool_visible(mgr, "push", "mcp__push__extra_tool", 8.0), (
"pushed tools/list_changed never refreshed the catalog"
)
assert mgr._static_servers["push"].session is session_before, (
"catalog arrived via teardown/reconnect, not via the push refresh"
)
# The merged map rebuilt AND the receive loop still routes:
# the brand-new tool dispatches end-to-end.
out = mgr.call_tool_sync("mcp__push__extra_tool", {"y": 21}, timeout=10)
assert "42" in out
finally:
if mgr is not None:
mgr.shutdown()
if proc is not None:
proc.kill()
proc.wait(timeout=5)