Skip to content

Commit eac9437

Browse files
committed
test(mcp): cover v2 reconnect, transport wiring, and session security
- Split test_invoke_hook_reconnects_on_session_terminated: the session-terminated success path (reconnect then retry succeeds) was dead code shadowed by a second plugin setup. Restore it as a real assertion and re-add the separate no-reconnect-on-other-errors test that had been merged away. - Assert streamable_http_client is called with a pre-built http_client instance and terminate_on_close=True (the v2 API change and DELETE-on-close behavior that replaced the removed __terminate_http_session). - Assert UDS servers populate _transport_security with DNS rebinding protection, non-UDS servers leave it unset, and run_streamable_http_async forwards both transport_security and the real bind host to streamable_http_app(). Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
1 parent 58acdbb commit eac9437

3 files changed

Lines changed: 92 additions & 0 deletions

File tree

tests/unit/cpex/framework/external/mcp/server/test_runtime_coverage.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,48 @@ def test_uds_sets_transport_security(self, tmp_path):
4949
config = MCPServerConfig(host="127.0.0.1", port=8000, uds=uds_path)
5050
server = runtime.SSLCapableMCPServer(server_config=config, name="UDSTest")
5151
assert server.server_config.uds == uds_path
52+
# UDS servers get DNS rebinding protection auto-configured in __init__.
53+
assert server._transport_security is not None
54+
assert server._transport_security.enable_dns_rebinding_protection is True
55+
56+
def test_non_uds_leaves_transport_security_unset(self):
57+
config = MCPServerConfig(host="0.0.0.0", port=8000)
58+
server = runtime.SSLCapableMCPServer(server_config=config, name="NonUDSTest")
59+
# Non-UDS servers rely on the SDK/host handling, not the UDS auto-config.
60+
assert server._transport_security is None
61+
62+
@pytest.mark.asyncio
63+
async def test_run_streamable_http_async_forwards_transport_security(self, tmp_path, monkeypatch):
64+
"""run_streamable_http_async must forward _transport_security to streamable_http_app()."""
65+
uds_path = str(tmp_path / "plugin.sock")
66+
config = MCPServerConfig(host="127.0.0.1", port=8000, uds=uds_path)
67+
server = runtime.SSLCapableMCPServer(server_config=config, name="ForwardTest")
68+
69+
captured = {}
70+
71+
def capture_app(**kwargs):
72+
captured.update(kwargs)
73+
return SimpleNamespace(routes=[])
74+
75+
server.streamable_http_app = capture_app
76+
monkeypatch.setattr(runtime.SSLCapableMCPServer, "_get_ssl_config", lambda self: {})
77+
78+
class DummyServer:
79+
def __init__(self, config):
80+
self.config = config
81+
82+
async def serve(self):
83+
pass
84+
85+
monkeypatch.setattr(runtime.uvicorn, "Config", lambda **kwargs: SimpleNamespace(**kwargs))
86+
monkeypatch.setattr(runtime.uvicorn, "Server", lambda config: DummyServer(config))
87+
88+
await runtime.SSLCapableMCPServer.run_streamable_http_async(server)
89+
90+
assert captured["transport_security"] is server._transport_security
91+
assert captured["transport_security"].enable_dns_rebinding_protection is True
92+
# Real bind host is forwarded so the SDK does not force a localhost-only allowlist.
93+
assert captured["host"] == "127.0.0.1"
5294

5395
def test_ssl_config_partial_tls_warns(self, tmp_path, caplog):
5496
"""TLS present but no keyfile/certfile returns empty dict + warning."""

tests/unit/cpex/framework/external/mcp/test_client_coverage.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,41 @@ def mock_streamable(*args, **kwargs):
237237
with pytest.raises(PluginError, match="connection failed after 3 attempts"):
238238
await plugin._ExternalPlugin__connect_to_http_server("http://localhost:9999/mcp")
239239

240+
@pytest.mark.asyncio
241+
async def test_streamable_client_called_with_prebuilt_client_and_terminate_on_close(self):
242+
"""v2 SDK contract: streamable_http_client receives a pre-built httpx client and
243+
terminate_on_close=True (replacing the removed manual __terminate_http_session)."""
244+
plugin = _make_plugin()
245+
246+
class OkCtx:
247+
async def __aenter__(self):
248+
return AsyncMock(), AsyncMock()
249+
250+
async def __aexit__(self, *args):
251+
return False
252+
253+
mock_session = AsyncMock()
254+
mock_session.initialize = AsyncMock()
255+
list_tools_result = MagicMock()
256+
list_tools_result.tools = []
257+
mock_session.list_tools = AsyncMock(return_value=list_tools_result)
258+
259+
prebuilt_client = AsyncMock(spec=httpx.AsyncClient)
260+
261+
with (
262+
patch("cpex.framework.external.mcp.client.streamable_http_client", return_value=OkCtx()) as mock_streamable,
263+
patch("cpex.framework.external.mcp.client.ClientSession", return_value=mock_session),
264+
patch("cpex.framework.external.mcp.client.httpx.AsyncClient", return_value=prebuilt_client),
265+
):
266+
plugin._exit_stack = AsyncExitStack()
267+
await plugin._ExternalPlugin__connect_to_http_server("http://localhost:9999/mcp")
268+
269+
mock_streamable.assert_called_once()
270+
_, kwargs = mock_streamable.call_args
271+
assert kwargs["terminate_on_close"] is True
272+
# A pre-built AsyncClient instance is passed, not a factory callable (v2 API change).
273+
assert kwargs["http_client"] is prebuilt_client
274+
240275

241276
# ===========================================================================
242277
# Shutdown

tests/unit/cpex/framework/external/mcp/test_client_reconnect.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,21 @@ async def mock_call_tool(*args, **kwargs):
248248
from mcp_types import CallToolResult, TextContent
249249

250250
return CallToolResult(content=[TextContent(type="text", text='{"result": {"name": "test", "args": {}}}')])
251+
252+
mock_session.call_tool = mock_call_tool
253+
254+
with patch("cpex.framework.external.mcp.client.get_hook_registry") as mock_registry:
255+
mock_registry.return_value.get_result_type.return_value = ToolPreInvokePayload
256+
with patch.object(plugin, "_reconnect_session", new_callable=AsyncMock) as mock_reconnect:
257+
payload = ToolPreInvokePayload(name="test", args={})
258+
result = await plugin.invoke_hook("tool_pre_invoke", payload, mock_plugin_context)
259+
mock_reconnect.assert_called_once()
260+
assert result is not None
261+
# The call is retried after reconnect: first raises, second succeeds.
262+
assert call_count == 2
263+
264+
@pytest.mark.asyncio
265+
async def test_invoke_hook_no_reconnect_on_other_plugin_errors(self, mock_http_plugin_config, mock_plugin_context):
251266
plugin = ExternalPlugin(mock_http_plugin_config)
252267
mock_session = AsyncMock()
253268
plugin._session = mock_session

0 commit comments

Comments
 (0)