Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions python/semantic_kernel/connectors/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from itertools import chain
from typing import TYPE_CHECKING, Any

import httpx
from mcp import types
from mcp.client.session import ClientSession
from mcp.client.sse import sse_client
Expand Down Expand Up @@ -316,13 +317,15 @@ async def _inner_connect(self, ready_event: asyncio.Event) -> None:
)
except Exception as ex:
await self._exit_stack.aclose()
ready_event.set()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ready_event.set() fix prevents connect() from hanging when session creation fails. Add a test that mocks ClientSession(...) to raise and asserts that KernelPluginInvalidConfigurationError is raised (rather than a hang).

raise KernelPluginInvalidConfigurationError(
"Failed to create a session. Please check your configuration."
) from ex
try:
await session.initialize()
except Exception as ex:
await self._exit_stack.aclose()
ready_event.set()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above — this ready_event.set() fix for initialization failure also needs a test. Mock session.initialize() to raise and assert proper error propagation.

raise KernelPluginInvalidConfigurationError(
"Failed to initialize session. Please check your configuration."
) from ex
Expand Down Expand Up @@ -760,6 +763,29 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
args.update(self._client_kwargs)
return streamablehttp_client(**args)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This preflight GET uses the wrong HTTP method (streamablehttp_client uses POST) and runs unconditionally — even when self.session is already provided, breaking existing tests that supply a mock session without mocking httpx.AsyncClient. Consider removing the preflight entirely (auth errors now propagate via _inner_connect thanks to the ready_event.set() fixes) or at minimum guarding with if not self.session:.

Suggested change
async def __aenter__(self) -> Self:
"""Fail fast on authentication/authorization errors before connecting."""
if not self.session:
timeout = self.timeout if self.timeout is not None else 30
try:
async with httpx.AsyncClient(timeout=timeout, headers=self.headers) as client:
response = await client.get(self.url)
if response.status_code in (httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN):
raise KernelPluginInvalidConfigurationError(
f"Failed to connect to the MCP server: received HTTP {response.status_code} (unauthorized/forbidden)."
)
except KernelPluginInvalidConfigurationError:
raise
except Exception as ex: # pragma: no cover - guarded for unexpected failures
raise KernelPluginInvalidConfigurationError(
"Failed to connect to the MCP server. Please check your configuration."
) from ex

async def __aenter__(self) -> Self:
"""Fail fast on authentication/authorization errors before connecting."""
if not self.session:
timeout = self.timeout if self.timeout is not None else 30
client_kwargs: dict[str, Any] = {"timeout": timeout, "headers": self.headers}
if "auth" in self._client_kwargs:
client_kwargs["auth"] = self._client_kwargs["auth"]
try:
async with httpx.AsyncClient(**client_kwargs) as client:
response = await client.get(self.url)
Comment on lines +769 to +775

Copilot AI Apr 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preflight timeout is passed directly into httpx.AsyncClient. In this repo, MCPStreamableHttpPlugin is used with timeout=timedelta(...) (e.g., the OAuth sample), which httpx does not accept as a timeout value. Consider normalizing self.timeout to a value compatible with httpx (e.g., convert timedelta to seconds or build an httpx.Timeout) before constructing the client so preflight doesn’t break existing callers.

Copilot uses AI. Check for mistakes.
if response.status_code in (httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN):
raise KernelPluginInvalidConfigurationError(
f"Failed to connect to the MCP server: received HTTP {response.status_code} (unauthorized/forbidden)."
)
Comment on lines +775 to +779

Copilot AI Apr 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says non-success HTTP responses should be raised early, but the preflight currently only errors on 401/403 and silently proceeds on other error codes (e.g., 404/500). Consider treating any non-2xx response as a configuration/connectivity error (while still providing a tailored message for 401/403).

Copilot uses AI. Check for mistakes.
Comment on lines +775 to +779

Copilot AI Apr 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The preflight uses client.get(self.url), which by default reads the full response body. For a “streamable” endpoint this can block until timeout if the server keeps the response open. Consider issuing the request in streaming mode (so you only read headers/status) and closing the response immediately after checking status_code, to avoid unnecessary latency/hangs during preflight.

Suggested change
response = await client.get(self.url)
if response.status_code in (httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN):
raise KernelPluginInvalidConfigurationError(
f"Failed to connect to the MCP server: received HTTP {response.status_code} (unauthorized/forbidden)."
)
async with client.stream("GET", self.url) as response:
if response.status_code in (httpx.codes.UNAUTHORIZED, httpx.codes.FORBIDDEN):
raise KernelPluginInvalidConfigurationError(
f"Failed to connect to the MCP server: received HTTP {response.status_code} (unauthorized/forbidden)."
)

Copilot uses AI. Check for mistakes.
except KernelPluginInvalidConfigurationError:
raise
except Exception as ex: # pragma: no cover - guarded for unexpected failures
raise KernelPluginInvalidConfigurationError(
"Failed to connect to the MCP server. Please check your configuration."
) from ex

return await super().__aenter__()


class MCPWebsocketPlugin(MCPPluginBase):
"""MCP websocket server configuration."""
Expand Down
113 changes: 113 additions & 0 deletions python/tests/unit/connectors/mcp/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,119 @@ async def test_with_kwargs_streamablehttp(mock_session, mock_client, list_tool_c
assert len(loaded_plugin.functions["func2"].parameters) == 0


class _DummyHttpxClient:
def __init__(self, status_code: int):
self.status_code = status_code

async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc_val, exc_tb):
return False

async def get(self, url: str): # pragma: no cover - trivial
import httpx

return httpx.Response(self.status_code, request=httpx.Request("GET", url))


@pytest.mark.parametrize("status_code", [401, 403])
@patch("semantic_kernel.connectors.mcp.ClientSession")
@patch("semantic_kernel.connectors.mcp.streamablehttp_client")
async def test_streamable_http_raises_on_401_403(mock_streamable_client, mock_session, monkeypatch, status_code):
monkeypatch.setattr("semantic_kernel.connectors.mcp.httpx.AsyncClient", lambda **_: _DummyHttpxClient(status_code))
Comment on lines +262 to +264

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The monkeypatch fixture must appear before the @patch parameters in the function signature. With @patch decorators, mock arguments are injected in reverse decorator order (bottom-up), so the correct order is (mock_session, mock_streamable_client, monkeypatch, status_code). As written, mock_streamable_client receives the ClientSession mock and vice versa, swapping their intended assignments.

Suggested change
@patch("semantic_kernel.connectors.mcp.streamablehttp_client")
async def test_streamable_http_raises_on_401_403(mock_streamable_client, mock_session, monkeypatch, status_code):
# Avoid real network: preflight should catch unauthorized/forbidden before streamable client is used.
monkeypatch.setattr("semantic_kernel.connectors.mcp.httpx.AsyncClient", lambda **_: _DummyHttpxClient(status_code))
async def test_streamable_http_raises_on_401_403(mock_session, mock_streamable_client, monkeypatch, status_code):


with pytest.raises(KernelPluginInvalidConfigurationError):
async with MCPStreamableHttpPlugin(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good coverage for 401/403, but the raise_for_status() path for other server errors (e.g., 500, 502) is not tested. Add a parametrized case with a 5xx status code to verify behavior.

name="TestMCPPlugin",
description="Test MCP Plugin",
url="http://localhost:8080/mcp",
):
pass


@patch("semantic_kernel.connectors.mcp.ClientSession")
@patch("semantic_kernel.connectors.mcp.streamablehttp_client")
async def test_streamable_http_allows_success(mock_streamable_client, mock_session, monkeypatch):
monkeypatch.setattr("semantic_kernel.connectors.mcp.httpx.AsyncClient", lambda **_: _DummyHttpxClient(200))

mock_read = MagicMock()
mock_write = MagicMock()
mock_callback = MagicMock()

mock_generator = MagicMock()
mock_generator.__aenter__.return_value = (mock_read, mock_write, mock_callback)
mock_generator.__aexit__.return_value = (mock_read, mock_write, mock_callback)
mock_streamable_client.return_value = mock_generator
mock_session.return_value.__aenter__.return_value.list_tools.return_value = []
mock_session.return_value.initialize = AsyncMock()

async with MCPStreamableHttpPlugin(
name="TestMCPPlugin",
description="Test MCP Plugin",
url="http://localhost:8080/mcp",
) as plugin:
assert plugin is not None


@patch("semantic_kernel.connectors.mcp.ClientSession")
@patch("semantic_kernel.connectors.mcp.streamablehttp_client")
async def test_streamable_http_skips_preflight_with_existing_session(mock_streamable_client, mock_session):
"""Preflight is skipped when a session is already provided."""
existing_session = MagicMock()
existing_session._request_id = 1
existing_session.list_tools = AsyncMock(return_value=MagicMock(tools=[]))

plugin = MCPStreamableHttpPlugin(
name="TestMCPPlugin",
description="Test MCP Plugin",
url="http://localhost:8080/mcp",
session=existing_session,
)
await plugin.connect()
assert plugin.session is existing_session
await plugin.close()


@patch("semantic_kernel.connectors.mcp.streamablehttp_client")
async def test_streamable_http_session_create_failure_does_not_hang(mock_streamable_client):
"""ready_event.set() prevents connect() from hanging when session creation fails."""
mock_streamable_client.side_effect = RuntimeError("connection refused")

with pytest.raises(KernelPluginInvalidConfigurationError, match="create a session"):
async with MCPStreamableHttpPlugin(
name="TestMCPPlugin",
description="Test MCP Plugin",
url="http://localhost:8080/mcp",
):
pass
Comment on lines +318 to +329

Copilot AI Apr 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test sets streamablehttp_client.side_effect to raise, which fails during transport/client creation and should map to the connector error message about failing to connect to the MCP server (not “create a session”). The match="create a session" assertion is likely incorrect and may cause a false negative.

Copilot uses AI. Check for mistakes.


@patch("semantic_kernel.connectors.mcp.ClientSession")
@patch("semantic_kernel.connectors.mcp.streamablehttp_client")
async def test_streamable_http_session_init_failure_does_not_hang(mock_streamable_client, mock_session, monkeypatch):
"""ready_event.set() prevents connect() from hanging when session.initialize() fails."""
monkeypatch.setattr("semantic_kernel.connectors.mcp.httpx.AsyncClient", lambda **_: _DummyHttpxClient(200))

mock_read = MagicMock()
mock_write = MagicMock()
mock_callback = MagicMock()

mock_generator = MagicMock()
mock_generator.__aenter__.return_value = (mock_read, mock_write, mock_callback)
mock_generator.__aexit__.return_value = (mock_read, mock_write, mock_callback)
mock_streamable_client.return_value = mock_generator
mock_session.return_value.initialize = AsyncMock(side_effect=RuntimeError("init failed"))

Copilot AI Apr 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

session.initialize() is awaited on the entered ClientSession instance (the object returned from ClientSession().__aenter__() via AsyncExitStack.enter_async_context). This test sets initialize on mock_session.return_value, which may not be the same object, making the failure injection brittle. Prefer setting the side_effect on mock_session.return_value.__aenter__.return_value.initialize to ensure the initialization path actually raises.

Suggested change
mock_session.return_value.initialize = AsyncMock(side_effect=RuntimeError("init failed"))
mock_session.return_value.__aenter__.return_value.initialize = AsyncMock(
side_effect=RuntimeError("init failed")
)

Copilot uses AI. Check for mistakes.

with pytest.raises(KernelPluginInvalidConfigurationError, match="initialize session"):
async with MCPStreamableHttpPlugin(
name="TestMCPPlugin",
description="Test MCP Plugin",
url="http://localhost:8080/mcp",
):
pass


async def test_kernel_as_mcp_server(kernel: "Kernel", decorated_native_function, custom_plugin_class):
kernel.add_plugin(custom_plugin_class, "test")
kernel.add_functions("test", [decorated_native_function])
Expand Down
Loading