Skip to content

Commit 9e683ce

Browse files
Python: Propagate MCP initialization failures (#14095)
### Motivation and Context Closes #13414. An MCP initialization failure can leave `connect()` waiting on the background task's ready event. A later tool or prompt load failure can also leave the transport and session contexts open and the plugin holding a stale session. ### Description - Signal the ready event after failed setup has closed the exit stack, then propagate the original setup error. - Clear the background task and session even when the task failed, so a later `connect()` starts fresh. - Keep `KernelPluginInvalidConfigurationError` for transport/session initialization failures, including caller-supplied sessions, and wrap other setup failures in `FunctionExecutionException`. - Cover both initialization paths and both loader failures, including exception causes, an async context exit that yields, and a successful reconnect. ### Validation - `uv run --frozen pytest tests/unit/connectors/mcp -q` (69 passed) - `uv run --frozen ruff check semantic_kernel/connectors/mcp.py tests/unit/connectors/mcp/test_mcp.py` - `uv run --frozen ruff format --check semantic_kernel/connectors/mcp.py tests/unit/connectors/mcp/test_mcp.py` - `uv run --frozen mypy semantic_kernel/connectors/mcp.py` - `git diff --check` The new caller-supplied-session case fails before the fix with `FunctionExecutionException` and passes after it. Checking the full test file with the frozen local environment reports 43 method-assignment/union-attribute diagnostics in unchanged code; the parent version produces the same diagnostics. --------- Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
1 parent 6e5a4b7 commit 9e683ce

2 files changed

Lines changed: 142 additions & 55 deletions

File tree

‎python/semantic_kernel/connectors/mcp.py‎

Lines changed: 70 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import sys
88
from abc import abstractmethod
99
from collections.abc import Awaitable, Callable, Sequence
10-
from contextlib import AbstractAsyncContextManager, AsyncExitStack, _AsyncGeneratorContextManager
10+
from contextlib import AbstractAsyncContextManager, AsyncExitStack, _AsyncGeneratorContextManager, suppress
1111
from datetime import timedelta
1212
from functools import partial
1313
from itertools import chain
@@ -300,12 +300,14 @@ async def connect(self) -> None:
300300
try:
301301
self._current_task = asyncio.create_task(self._inner_connect(ready_event))
302302
await ready_event.wait()
303-
except KernelPluginInvalidConfigurationError:
304-
ready_event.clear()
305-
raise
303+
if self._current_task.done():
304+
await self._current_task
306305
except Exception as ex:
307306
ready_event.clear()
308-
await self.close()
307+
with suppress(Exception):
308+
await self.close()
309+
if isinstance(ex, KernelPluginInvalidConfigurationError):
310+
raise
309311
raise FunctionExecutionException("Failed to enter context manager.") from ex
310312

311313
async def close(self) -> None:
@@ -314,62 +316,76 @@ async def close(self) -> None:
314316
# Signal the stop event, which asks the _inner_connect
315317
# method to close the session with the exit stack
316318
self._stop_event.set()
317-
if self._current_task:
318-
# After, the signal, we wait for it to close the exit stack.
319-
await self._current_task
319+
try:
320+
if self._current_task:
321+
# After the signal, wait for the task to close the exit stack.
322+
await self._current_task
323+
finally:
320324
self._current_task = None
321-
self.session = None
325+
self.session = None
322326

323327
async def _inner_connect(self, ready_event: asyncio.Event) -> None:
324-
if not self.session:
325-
try:
326-
transport = await self._exit_stack.enter_async_context(self.get_mcp_client())
327-
except Exception as ex:
328-
await self._exit_stack.aclose()
329-
ready_event.set()
330-
raise KernelPluginInvalidConfigurationError(
331-
"Failed to connect to the MCP server. Please check your configuration."
332-
) from ex
333-
try:
334-
session = await self._exit_stack.enter_async_context(
335-
ClientSession(
336-
read_stream=transport[0],
337-
write_stream=transport[1],
338-
read_timeout_seconds=timedelta(seconds=self.request_timeout) if self.request_timeout else None,
339-
message_handler=self.message_handler,
340-
logging_callback=self.logging_callback,
341-
sampling_callback=self.sampling_callback,
328+
try:
329+
if not self.session:
330+
try:
331+
transport = await self._exit_stack.enter_async_context(self.get_mcp_client())
332+
except Exception as ex:
333+
raise KernelPluginInvalidConfigurationError(
334+
"Failed to connect to the MCP server. Please check your configuration."
335+
) from ex
336+
try:
337+
session = await self._exit_stack.enter_async_context(
338+
ClientSession(
339+
read_stream=transport[0],
340+
write_stream=transport[1],
341+
read_timeout_seconds=(
342+
timedelta(seconds=self.request_timeout) if self.request_timeout else None
343+
),
344+
message_handler=self.message_handler,
345+
logging_callback=self.logging_callback,
346+
sampling_callback=self.sampling_callback,
347+
)
342348
)
343-
)
344-
except Exception as ex:
345-
await self._exit_stack.aclose()
346-
raise KernelPluginInvalidConfigurationError(
347-
"Failed to create a session. Please check your configuration."
348-
) from ex
349+
except Exception as ex:
350+
raise KernelPluginInvalidConfigurationError(
351+
"Failed to create a session. Please check your configuration."
352+
) from ex
353+
try:
354+
await session.initialize()
355+
except Exception as ex:
356+
raise KernelPluginInvalidConfigurationError(
357+
"Failed to initialize session. Please check your configuration."
358+
) from ex
359+
self.session = session
360+
elif self.session._request_id == 0:
361+
# If the session is not initialized, we need to reinitialize it
362+
try:
363+
await self.session.initialize()
364+
except Exception as ex:
365+
raise KernelPluginInvalidConfigurationError(
366+
"Failed to initialize session. Please check your configuration."
367+
) from ex
368+
logger.debug("Connected to MCP server: %s", self.session)
369+
if self.load_tools_flag:
370+
await self.load_tools()
371+
if self.load_prompts_flag:
372+
await self.load_prompts()
373+
374+
if logger.level != logging.NOTSET:
375+
try:
376+
await self.session.set_logging_level(
377+
next(level for level, value in LOG_LEVEL_MAPPING.items() if value == logger.level)
378+
)
379+
except Exception:
380+
logger.warning("Failed to set log level to %s", logger.level)
381+
except Exception:
349382
try:
350-
await session.initialize()
351-
except Exception as ex:
352383
await self._exit_stack.aclose()
353-
raise KernelPluginInvalidConfigurationError(
354-
"Failed to initialize session. Please check your configuration."
355-
) from ex
356-
self.session = session
357-
elif self.session._request_id == 0:
358-
# If the session is not initialized, we need to reinitialize it
359-
await self.session.initialize()
360-
logger.debug("Connected to MCP server: %s", self.session)
361-
if self.load_tools_flag:
362-
await self.load_tools()
363-
if self.load_prompts_flag:
364-
await self.load_prompts()
365-
366-
if logger.level != logging.NOTSET:
367-
try:
368-
await self.session.set_logging_level(
369-
next(level for level, value in LOG_LEVEL_MAPPING.items() if value == logger.level)
370-
)
371384
except Exception:
372-
logger.warning("Failed to set log level to %s", logger.level)
385+
logger.exception("Error closing exit stack after failed connect")
386+
finally:
387+
ready_event.set()
388+
raise
373389
# Setting up is complete, will now signal the main loop that we are ready
374390
ready_event.set()
375391
# Create a stop event to signal the exit stack to close

‎python/tests/unit/connectors/mcp/test_mcp.py‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

3+
import asyncio
34
import logging
45
import re
56
from unittest.mock import AsyncMock, MagicMock, patch
@@ -17,7 +18,7 @@
1718
create_mcp_server_from_functions,
1819
create_mcp_server_from_kernel,
1920
)
20-
from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError
21+
from semantic_kernel.exceptions import FunctionExecutionException, KernelPluginInvalidConfigurationError
2122
from semantic_kernel.functions import KernelFunction, KernelPlugin, kernel_function
2223

2324

@@ -255,6 +256,76 @@ async def test_mcp_plugin_failed_get_session():
255256
pass
256257

257258

259+
@pytest.mark.parametrize("supplied_session", [False, True])
260+
@patch("semantic_kernel.connectors.mcp.streamablehttp_client")
261+
@patch("semantic_kernel.connectors.mcp.ClientSession")
262+
async def test_streamable_http_initialization_failure_unblocks_connect(mock_session, mock_client, supplied_session):
263+
mock_read = MagicMock()
264+
mock_write = MagicMock()
265+
mock_callback = MagicMock()
266+
267+
mock_generator = MagicMock()
268+
mock_generator.__aenter__.return_value = (mock_read, mock_write, mock_callback)
269+
mock_generator.__aexit__.return_value = (mock_read, mock_write, mock_callback)
270+
mock_client.return_value = mock_generator
271+
272+
session = mock_session.return_value.__aenter__.return_value
273+
session._request_id = 0
274+
initialization_error = RuntimeError("Unauthorized")
275+
session.initialize.side_effect = initialization_error
276+
277+
plugin = MCPStreamableHttpPlugin(
278+
name="test",
279+
url="http://localhost:8080/mcp",
280+
session=session if supplied_session else None,
281+
load_tools=False,
282+
load_prompts=False,
283+
)
284+
285+
with pytest.raises(KernelPluginInvalidConfigurationError, match="Failed to initialize session") as exc_info:
286+
await asyncio.wait_for(plugin.connect(), timeout=1)
287+
288+
assert exc_info.value.__cause__ is initialization_error
289+
assert plugin.session is None
290+
291+
292+
@pytest.mark.parametrize("failed_loader", ["load_tools", "load_prompts"])
293+
@patch("semantic_kernel.connectors.mcp.streamablehttp_client")
294+
@patch("semantic_kernel.connectors.mcp.ClientSession")
295+
async def test_loader_failure_closes_contexts_and_allows_reconnect(mock_session, mock_client, failed_loader):
296+
transport = MagicMock()
297+
transport.__aenter__.return_value = (MagicMock(), MagicMock(), MagicMock())
298+
mock_client.return_value = transport
299+
session_context = mock_session.return_value
300+
301+
async def exit_after_yield(*args):
302+
await asyncio.sleep(0)
303+
return False
304+
305+
session_context.__aexit__.side_effect = exit_after_yield
306+
plugin = MCPStreamableHttpPlugin(
307+
name="test",
308+
url="http://localhost:8080/mcp",
309+
load_tools=failed_loader == "load_tools",
310+
load_prompts=failed_loader == "load_prompts",
311+
)
312+
setattr(plugin, failed_loader, AsyncMock(side_effect=RuntimeError("load failed")))
313+
314+
with pytest.raises(FunctionExecutionException, match="Failed to enter context manager"):
315+
await asyncio.wait_for(plugin.connect(), timeout=1)
316+
317+
session_context.__aexit__.assert_awaited_once()
318+
transport.__aexit__.assert_awaited_once()
319+
assert plugin.session is None
320+
321+
setattr(plugin, failed_loader, AsyncMock())
322+
await asyncio.wait_for(plugin.connect(), timeout=1)
323+
assert plugin.session is session_context.__aenter__.return_value
324+
await asyncio.wait_for(plugin.close(), timeout=1)
325+
assert session_context.__aexit__.await_count == 2
326+
assert transport.__aexit__.await_count == 2
327+
328+
258329
@patch("semantic_kernel.connectors.mcp.stdio_client")
259330
@patch("semantic_kernel.connectors.mcp.ClientSession")
260331
async def test_with_kwargs_stdio(mock_session, mock_client, list_tool_calls, kernel: "Kernel"):

0 commit comments

Comments
 (0)