chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
# 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"
|
||||
Reference in New Issue
Block a user