# Copyright (c) Microsoft. All rights reserved. import logging import re from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock, patch import pytest from mcp import ClientSession, ListToolsResult, StdioServerParameters, Tool, types from semantic_kernel.connectors.mcp import MCPSsePlugin, MCPStdioPlugin, MCPStreamableHttpPlugin, MCPWebsocketPlugin from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError if TYPE_CHECKING: from semantic_kernel import Kernel @pytest.fixture def list_tool_calls_with_slash() -> ListToolsResult: return ListToolsResult( tools=[ Tool( name="nasa/get-astronomy-picture", description="func with slash", inputSchema={"properties": {}, "required": []}, ), Tool( name="weird\\name with spaces", description="func with backslash and spaces", inputSchema={"properties": {}, "required": []}, ), ] ) @pytest.fixture def list_tool_calls() -> ListToolsResult: return ListToolsResult( tools=[ Tool( name="func1", description="func1", inputSchema={ "properties": { "name": {"type": "string"}, }, "required": ["name"], }, ), Tool( name="func2", description="func2", inputSchema={}, ), ] ) @pytest.mark.parametrize( "plugin_class,plugin_args", [ (MCPSsePlugin, {"url": "http://localhost:8080/sse"}), (MCPStreamableHttpPlugin, {"url": "http://localhost:8080/mcp"}), ], ) async def test_mcp_plugin_session_not_initialize(plugin_class, plugin_args): # Test if Client can insert it's own Session mock_session = AsyncMock(spec=ClientSession) mock_session._request_id = 0 mock_session.initialize = AsyncMock() async with plugin_class(name="test", session=mock_session, **plugin_args) as plugin: assert plugin.session is mock_session assert mock_session.initialize.called @pytest.mark.parametrize( "plugin_class,plugin_args", [ (MCPSsePlugin, {"url": "http://localhost:8080/sse"}), (MCPStreamableHttpPlugin, {"url": "http://localhost:8080/mcp"}), ], ) async def test_mcp_plugin_session_initialized(plugin_class, plugin_args): # Test if Client can insert it's own initialized Session mock_session = AsyncMock(spec=ClientSession) mock_session._request_id = 1 mock_session.initialize = AsyncMock() async with plugin_class(name="test", session=mock_session, **plugin_args) as plugin: assert plugin.session is mock_session assert not mock_session.initialize.called async def test_mcp_sampling_denied_by_consent_callback(): sampling_consent_callback = AsyncMock(return_value=False) plugin = MCPSsePlugin( name="TestMCPPlugin", url="http://localhost:8080/sse", sampling_consent_callback=sampling_consent_callback, ) params = types.CreateMessageRequestParams( messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))], systemPrompt="server instructions", maxTokens=100, ) result = await plugin.sampling_callback(MagicMock(), params) sampling_consent_callback.assert_awaited_once_with("TestMCPPlugin", params) assert isinstance(result, types.ErrorData) assert result.message == "Sampling denied by policy." async def test_mcp_sampling_consent_callback_error_denies_request(caplog): sampling_consent_callback = AsyncMock(side_effect=RuntimeError("policy failure")) plugin = MCPSsePlugin( name="TestMCPPlugin", url="http://localhost:8080/sse", sampling_consent_callback=sampling_consent_callback, ) params = types.CreateMessageRequestParams( messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))], systemPrompt="server instructions", maxTokens=100, ) with caplog.at_level(logging.ERROR, logger="semantic_kernel.connectors.mcp"): result = await plugin.sampling_callback(MagicMock(), params) sampling_consent_callback.assert_awaited_once_with("TestMCPPlugin", params) assert isinstance(result, types.ErrorData) assert result.message == "Sampling denied by policy." assert "MCP sampling consent callback failed" in caplog.text async def test_mcp_sampling_without_consent_callback_denies_by_default(caplog): plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse") params = types.CreateMessageRequestParams( messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))], systemPrompt="server instructions", maxTokens=100, ) with caplog.at_level(logging.WARNING, logger="semantic_kernel.connectors.mcp"): result = await plugin.sampling_callback(MagicMock(), params) assert isinstance(result, types.ErrorData) assert result.message == "Sampling denied: no consent callback configured." assert "denied because no sampling consent callback was configured" in caplog.text async def test_mcp_sampling_auto_approve_logs_warning(caplog): plugin = MCPSsePlugin( name="TestMCPPlugin", url="http://localhost:8080/sse", sampling_auto_approve=True, ) params = types.CreateMessageRequestParams( messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))], systemPrompt="server instructions", maxTokens=100, ) with caplog.at_level(logging.WARNING, logger="semantic_kernel.connectors.mcp"): result = await plugin.sampling_callback(MagicMock(), params) # No kernel configured, so the request is approved but then fails for lack of a chat service. assert isinstance(result, types.ErrorData) assert "auto-approved because sampling_auto_approve is enabled" in caplog.text async def test_mcp_tool_and_prompt_names_do_not_shadow_plugin_attributes(): kernel = MagicMock() plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse", kernel=kernel) session = AsyncMock(spec=ClientSession) session.list_tools.return_value = ListToolsResult( tools=[ Tool(name="kernel", description="reserved", inputSchema={}), Tool(name="safe_tool", description="safe", inputSchema={}), ] ) session.list_prompts.return_value = types.ListPromptsResult( prompts=[ types.Prompt(name="session", description="reserved", arguments=[]), types.Prompt(name="safe_prompt", description="safe", arguments=[]), ] ) plugin.session = session await plugin.load_tools() assert plugin.kernel is kernel assert hasattr(plugin, "safe_tool") await plugin.load_prompts() assert plugin.session is session assert hasattr(plugin, "safe_prompt") async def test_mcp_tool_and_prompt_names_can_reload_existing_mcp_functions(): plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse") session = AsyncMock(spec=ClientSession) session.list_tools.side_effect = [ ListToolsResult(tools=[Tool(name="safe_tool", description="first tool", inputSchema={})]), ListToolsResult(tools=[Tool(name="safe_tool", description="second tool", inputSchema={})]), ] session.list_prompts.side_effect = [ types.ListPromptsResult(prompts=[types.Prompt(name="safe_prompt", description="first prompt", arguments=[])]), types.ListPromptsResult(prompts=[types.Prompt(name="safe_prompt", description="second prompt", arguments=[])]), ] plugin.session = session await plugin.load_tools() first_tool = plugin.safe_tool await plugin.load_tools() assert plugin.safe_tool is not first_tool assert plugin.safe_tool.__kernel_function_description__ == "second tool" await plugin.load_prompts() first_prompt = plugin.safe_prompt await plugin.load_prompts() assert plugin.safe_prompt is not first_prompt assert plugin.safe_prompt.__kernel_function_description__ == "second prompt" async def test_mcp_plugin_failed_get_session(): with ( patch("semantic_kernel.connectors.mcp.stdio_client") as mock_stdio_client, ): mock_read = MagicMock() mock_write = MagicMock() mock_generator = MagicMock() # Make the mock_stdio_client return an AsyncMock for the context manager mock_generator.__aenter__.side_effect = Exception("Connection failed") mock_generator.__aexit__.return_value = (mock_read, mock_write) # Make the mock_stdio_client return an AsyncMock for the context manager mock_stdio_client.return_value = mock_generator with pytest.raises(KernelPluginInvalidConfigurationError): async with MCPStdioPlugin( name="test", command="echo", args=["Hello"], ): pass @patch("semantic_kernel.connectors.mcp.stdio_client") @patch("semantic_kernel.connectors.mcp.ClientSession") async def test_with_kwargs_stdio(mock_session, mock_client, list_tool_calls, kernel: "Kernel"): mock_read = MagicMock() mock_write = MagicMock() mock_generator = MagicMock() # Make the mock_stdio_client return an AsyncMock for the context manager mock_generator.__aenter__.return_value = (mock_read, mock_write) mock_generator.__aexit__.return_value = (mock_read, mock_write) # Make the mock_stdio_client return an AsyncMock for the context manager mock_client.return_value = mock_generator mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls async with MCPStdioPlugin( name="TestMCPPlugin", description="Test MCP Plugin", command="uv", args=["--directory", "path", "run", "file.py"], ) as plugin: mock_client.assert_called_once_with( server=StdioServerParameters(command="uv", args=["--directory", "path", "run", "file.py"]) ) loaded_plugin = kernel.add_plugin(plugin) assert loaded_plugin is not None assert loaded_plugin.name == "TestMCPPlugin" assert loaded_plugin.description == "Test MCP Plugin" assert loaded_plugin.functions.get("func1") is not None assert loaded_plugin.functions["func1"].parameters[0].name == "name" assert loaded_plugin.functions["func1"].parameters[0].is_required assert loaded_plugin.functions.get("func2") is not None assert len(loaded_plugin.functions["func2"].parameters) == 0 @patch("semantic_kernel.connectors.mcp.websocket_client") @patch("semantic_kernel.connectors.mcp.ClientSession") async def test_with_kwargs_websocket(mock_session, mock_client, list_tool_calls, kernel: "Kernel"): mock_read = MagicMock() mock_write = MagicMock() mock_generator = MagicMock() # Make the mock_stdio_client return an AsyncMock for the context manager mock_generator.__aenter__.return_value = (mock_read, mock_write) mock_generator.__aexit__.return_value = (mock_read, mock_write) # Make the mock_stdio_client return an AsyncMock for the context manager mock_client.return_value = mock_generator mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls async with MCPWebsocketPlugin( name="TestMCPPlugin", description="Test MCP Plugin", url="http://localhost:8080/websocket", ) as plugin: mock_client.assert_called_once_with(url="http://localhost:8080/websocket") loaded_plugin = kernel.add_plugin(plugin) assert loaded_plugin is not None assert loaded_plugin.name == "TestMCPPlugin" assert loaded_plugin.description == "Test MCP Plugin" assert loaded_plugin.functions.get("func1") is not None assert loaded_plugin.functions["func1"].parameters[0].name == "name" assert loaded_plugin.functions["func1"].parameters[0].is_required assert loaded_plugin.functions.get("func2") is not None assert len(loaded_plugin.functions["func2"].parameters) == 0 @patch("semantic_kernel.connectors.mcp.sse_client") @patch("semantic_kernel.connectors.mcp.ClientSession") async def test_with_kwargs_sse(mock_session, mock_client, list_tool_calls, kernel: "Kernel"): mock_read = MagicMock() mock_write = MagicMock() mock_generator = MagicMock() # Make the mock_stdio_client return an AsyncMock for the context manager mock_generator.__aenter__.return_value = (mock_read, mock_write) mock_generator.__aexit__.return_value = (mock_read, mock_write) # Make the mock_stdio_client return an AsyncMock for the context manager mock_client.return_value = mock_generator mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls async with MCPSsePlugin( name="TestMCPPlugin", description="Test MCP Plugin", url="http://localhost:8080/sse", ) as plugin: mock_client.assert_called_once_with(url="http://localhost:8080/sse") loaded_plugin = kernel.add_plugin(plugin) assert loaded_plugin is not None assert loaded_plugin.name == "TestMCPPlugin" assert loaded_plugin.description == "Test MCP Plugin" assert loaded_plugin.functions.get("func1") is not None assert loaded_plugin.functions["func1"].parameters[0].name == "name" assert loaded_plugin.functions["func1"].parameters[0].is_required assert loaded_plugin.functions.get("func2") is not None assert len(loaded_plugin.functions["func2"].parameters) == 0 @patch("semantic_kernel.connectors.mcp.streamablehttp_client") @patch("semantic_kernel.connectors.mcp.ClientSession") async def test_with_kwargs_streamablehttp(mock_session, mock_client, list_tool_calls, kernel: "Kernel"): mock_read = MagicMock() mock_write = MagicMock() mock_callback = MagicMock() mock_generator = MagicMock() # Make the mock_streamablehttp_client return an AsyncMock for the context manager mock_generator.__aenter__.return_value = (mock_read, mock_write, mock_callback) mock_generator.__aexit__.return_value = (mock_read, mock_write, mock_callback) # Make the mock_streamablehttp_client return an AsyncMock for the context manager mock_client.return_value = mock_generator mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls async with MCPStreamableHttpPlugin( name="TestMCPPlugin", description="Test MCP Plugin", url="http://localhost:8080/mcp", ) as plugin: mock_client.assert_called_once_with(url="http://localhost:8080/mcp") loaded_plugin = kernel.add_plugin(plugin) assert loaded_plugin is not None assert loaded_plugin.name == "TestMCPPlugin" assert loaded_plugin.description == "Test MCP Plugin" assert loaded_plugin.functions.get("func1") is not None assert loaded_plugin.functions["func1"].parameters[0].name == "name" assert loaded_plugin.functions["func1"].parameters[0].is_required assert loaded_plugin.functions.get("func2") is not None assert len(loaded_plugin.functions["func2"].parameters) == 0 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]) server = kernel.as_mcp_server() assert server is not None assert types.PingRequest in server.request_handlers assert types.ListToolsRequest in server.request_handlers assert types.CallToolRequest in server.request_handlers assert server.name == "Semantic Kernel MCP Server" @patch("semantic_kernel.connectors.mcp.sse_client") @patch("semantic_kernel.connectors.mcp.ClientSession") async def test_mcp_tool_name_normalization(mock_session, mock_client, list_tool_calls_with_slash, kernel: "Kernel"): """Test that MCP tool names with illegal characters are normalized.""" mock_read = MagicMock() mock_write = MagicMock() mock_generator = MagicMock() mock_generator.__aenter__.return_value = (mock_read, mock_write) mock_generator.__aexit__.return_value = (mock_read, mock_write) mock_client.return_value = mock_generator mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls_with_slash async with MCPSsePlugin( name="TestMCPPlugin", description="Test MCP Plugin", url="http://localhost:8080/sse", ) as plugin: loaded_plugin = kernel.add_plugin(plugin) # The normalized names: assert "nasa-get-astronomy-picture" in loaded_plugin.functions assert "weird-name-with-spaces" in loaded_plugin.functions # They should not exist with their original (invalid) names: assert "nasa/get-astronomy-picture" not in loaded_plugin.functions assert "weird\\name with spaces" not in loaded_plugin.functions normalized_names = list(loaded_plugin.functions.keys()) for name in normalized_names: assert re.match(r"^[A-Za-z0-9_.-]+$", name) @patch("semantic_kernel.connectors.mcp.ClientSession") async def test_mcp_normalization_function(mock_session, list_tool_calls_with_slash): """Unit test for the normalize_mcp_name function (should exist in codebase).""" from semantic_kernel.connectors.mcp import _normalize_mcp_name assert _normalize_mcp_name("nasa/get-astronomy-picture") == "nasa-get-astronomy-picture" assert _normalize_mcp_name("weird\\name with spaces") == "weird-name-with-spaces" assert _normalize_mcp_name("simple_name") == "simple_name" assert _normalize_mcp_name("Name-With.Dots_And-Hyphens") == "Name-With.Dots_And-Hyphens" async def test_excluded_function_cannot_be_called(kernel: "Kernel"): """Test that excluded functions are rejected at call time, not just hidden from listing.""" from semantic_kernel.connectors.mcp import create_mcp_server_from_kernel from semantic_kernel.functions.kernel_function_decorator import kernel_function side_effect_called = False @kernel_function(name="public_echo") def public_echo(message: str) -> str: return f"echo: {message}" @kernel_function(name="secret_admin") def secret_admin(target: str) -> str: nonlocal side_effect_called side_effect_called = True return f"privileged action on {target}" kernel.add_function(plugin_name="tools", function=public_echo) kernel.add_function(plugin_name="tools", function=secret_admin) server = create_mcp_server_from_kernel(kernel, excluded_functions=["secret_admin"]) # Verify the server was created with handlers assert types.ListToolsRequest in server.request_handlers assert types.CallToolRequest in server.request_handlers # Mock _get_cached_tool_definition to bypass SDK request context requirements # (normally set by a real MCP session transport) async def _fake_get_cached_tool_definition(tool_name): return None server._get_cached_tool_definition = _fake_get_cached_tool_definition # Build a proper CallToolRequest as the MCP SDK would send call_tool_request = types.CallToolRequest( method="tools/call", params=types.CallToolRequestParams(name="secret_admin", arguments={}), ) # The internal handler wraps our _call_tool; invoke via the registered handler handler = server.request_handlers[types.CallToolRequest] result = await handler(call_tool_request) # The call must fail (isError=True) with the correct error message assert result.root.isError is True, "Calling an excluded function should return an error" assert any("Unknown tool" in c.text for c in result.root.content if hasattr(c, "text")), ( f"Expected 'Unknown tool' error, got: {result.root.content}" ) assert not side_effect_called, "Excluded function's side effect should not have fired"