From 10975276a13f4f03a7952f10dbf428258053057f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nico=20M=C3=B6ller?= Date: Tue, 1 Apr 2025 14:43:14 +0200 Subject: [PATCH 01/19] Python: Add initial MCP Connector Version (#10778) With MCP getting more and more popular, i thought it's a cool idea to have plugins loaded and executed remotely with Semantic Kernel. What is included ? - Sample on how to use a MCP Server with Semantic Kernel - New Connector that can read Tools from the MCP Server - The Connector creates a Plugin with the corresponding MCP Tools - Connection can be done via stdio & sse What is **not** included ? - Loading of Resources, Prompts etc. - Handling Authentication to the MCP Server What needs to be done ? - [x] Load MCP Tools as Plugins - [x] Enhance Parameter Parsing between KernelParameters and MCP Tool Parameters - [x] Add Unit Tests - [x] Add Integration Tests - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass, and I have added new tests where possible - [x] I didn't break anyone :smile: --------- Co-authored-by: Eduard van Valkenburg --- python/pyproject.toml | 3 + python/samples/concepts/mcp/mcp_connector.py | 129 ++++++++ .../connectors/mcp/__init__.py | 16 + .../connectors/mcp/mcp_manager.py | 64 ++++ .../mcp/mcp_server_execution_settings.py | 74 +++++ .../connectors/mcp/models/__init__.py | 0 .../connectors/mcp/models/mcp_tool.py | 36 +++ .../mcp/models/mcp_tool_parameters.py | 12 + .../functions/kernel_function_extension.py | 30 ++ .../functions/kernel_plugin.py | 28 ++ .../test_plugins/TestMCPPlugin/mcp_server.py | 23 ++ .../unit/connectors/mcp/test_mcp_manager.py | 166 +++++++++++ .../mcp/test_mcp_server_execution_settings.py | 102 +++++++ .../unit/functions/test_kernel_plugins.py | 21 ++ python/tests/unit/kernel/test_kernel.py | 22 ++ python/uv.lock | 275 ++++++++++-------- 16 files changed, 887 insertions(+), 114 deletions(-) create mode 100644 python/samples/concepts/mcp/mcp_connector.py create mode 100644 python/semantic_kernel/connectors/mcp/__init__.py create mode 100644 python/semantic_kernel/connectors/mcp/mcp_manager.py create mode 100644 python/semantic_kernel/connectors/mcp/mcp_server_execution_settings.py create mode 100644 python/semantic_kernel/connectors/mcp/models/__init__.py create mode 100644 python/semantic_kernel/connectors/mcp/models/mcp_tool.py create mode 100644 python/semantic_kernel/connectors/mcp/models/mcp_tool_parameters.py create mode 100644 python/tests/assets/test_plugins/TestMCPPlugin/mcp_server.py create mode 100644 python/tests/unit/connectors/mcp/test_mcp_manager.py create mode 100644 python/tests/unit/connectors/mcp/test_mcp_server_execution_settings.py diff --git a/python/pyproject.toml b/python/pyproject.toml index 6ac7f168c7d7..b9fd5ec621de 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -79,6 +79,9 @@ hugging_face = [ "sentence-transformers >= 2.2,< 5.0", "torch == 2.6.0" ] +mcp = [ + "mcp ~= 1.5" +] mongo = [ "pymongo >= 4.8.0, < 4.12", "motor >= 3.3.2,< 3.8.0" diff --git a/python/samples/concepts/mcp/mcp_connector.py b/python/samples/concepts/mcp/mcp_connector.py new file mode 100644 index 000000000000..9e43bd6a855c --- /dev/null +++ b/python/samples/concepts/mcp/mcp_connector.py @@ -0,0 +1,129 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from typing import TYPE_CHECKING + +from samples.concepts.setup.chat_completion_services import Services, get_chat_completion_service_and_request_settings +from semantic_kernel import Kernel +from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior +from semantic_kernel.connectors.mcp.mcp_server_execution_settings import MCPStdioServerExecutionSettings +from semantic_kernel.contents import ChatHistory +from semantic_kernel.functions import KernelArguments + +if TYPE_CHECKING: + pass + +##################################################################### +# This sample demonstrates how to build a conversational chatbot # +# using Semantic Kernel, featuring MCP Tools, # +# non-streaming responses, and support for math and time plugins. # +# The chatbot is designed to interact with the user, call functions # +# as needed, and return responses. # +##################################################################### + +# System message defining the behavior and persona of the chat bot. +system_message = """ +You are a chat bot. Your name is Mosscap and +you have one goal: figure out what people need. +Your full name, should you need to know it, is +Splendid Speckled Mosscap. You communicate +effectively, but you tend to answer with long +flowery prose. You are also a math wizard, +especially for adding and subtracting. +You also excel at joke telling, where your tone is often sarcastic. +Once you have the answer I am looking for, +you will return a full answer to me as soon as possible. +""" + +# Create and configure the kernel. +kernel = Kernel() + +# Define a chat function (a template for how to handle user input). +chat_function = kernel.add_function( + prompt="{{$chat_history}}{{$user_input}}", + plugin_name="ChatBot", + function_name="Chat", +) + +# You can select from the following chat completion services that support function calling: +# - Services.OPENAI +# - Services.AZURE_OPENAI +# - Services.AZURE_AI_INFERENCE +# - Services.ANTHROPIC +# - Services.BEDROCK +# - Services.GOOGLE_AI +# - Services.MISTRAL_AI +# - Services.OLLAMA +# - Services.ONNX +# - Services.VERTEX_AI +# - Services.DEEPSEEK +# Please make sure you have configured your environment correctly for the selected chat completion service. +chat_completion_service, request_settings = get_chat_completion_service_and_request_settings(Services.AZURE_OPENAI) + +# Configure the function choice behavior. Here, we set it to Auto, where auto_invoke=True by default. +# With `auto_invoke=True`, the model will automatically choose and call functions as needed. +request_settings.function_choice_behavior = FunctionChoiceBehavior.Auto(filters={"excluded_plugins": ["ChatBot"]}) + +kernel.add_service(chat_completion_service) + +# Pass the request settings to the kernel arguments. +arguments = KernelArguments(settings=request_settings) + +# Create a chat history to store the system message, initial messages, and the conversation. +history = ChatHistory() +history.add_system_message(system_message) + + +async def chat() -> bool: + """ + Continuously prompt the user for input and show the assistant's response. + Type 'exit' to exit. + """ + try: + user_input = input("User:> ") + except (KeyboardInterrupt, EOFError): + print("\n\nExiting chat...") + return False + + if user_input.lower().strip() == "exit": + print("\n\nExiting chat...") + return False + + arguments["user_input"] = user_input + arguments["chat_history"] = history + + # Handle non-streaming responses + result = await kernel.invoke(chat_function, arguments=arguments) + + # Update the chat history with the user's input and the assistant's response + if result: + print(f"Mosscap:> {result}") + history.add_user_message(user_input) + history.add_message(result.value[0]) # Capture the full context of the response + + return True + + +async def main() -> None: + # Make sure to have NPX installed and available in your PATH. + + # Find the NPX executable in the system PATH. + import shutil + + execution_settings = MCPStdioServerExecutionSettings( + command=shutil.which("npx"), + args=["-y", "@modelcontextprotocol/server-github"], + ) + + await kernel.add_plugin_from_mcp( + plugin_name="TestMCP", + execution_settings=execution_settings, + ) + print("Welcome to the chat bot!\n Type 'exit' to exit.\n") + chatting = True + while chatting: + chatting = await chat() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/semantic_kernel/connectors/mcp/__init__.py b/python/semantic_kernel/connectors/mcp/__init__.py new file mode 100644 index 000000000000..1d52dde8c37d --- /dev/null +++ b/python/semantic_kernel/connectors/mcp/__init__.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft. All rights reserved. +from semantic_kernel.connectors.mcp.mcp_server_execution_settings import ( + MCPServerExecutionSettings, + MCPSseServerExecutionSettings, + MCPStdioServerExecutionSettings, +) +from semantic_kernel.connectors.mcp.models.mcp_tool import MCPTool +from semantic_kernel.connectors.mcp.models.mcp_tool_parameters import MCPToolParameters + +__all__ = [ + "MCPServerExecutionSettings", + "MCPSseServerExecutionSettings", + "MCPStdioServerExecutionSettings", + "MCPTool", + "MCPToolParameters", +] diff --git a/python/semantic_kernel/connectors/mcp/mcp_manager.py b/python/semantic_kernel/connectors/mcp/mcp_manager.py new file mode 100644 index 000000000000..bb0b0faad310 --- /dev/null +++ b/python/semantic_kernel/connectors/mcp/mcp_manager.py @@ -0,0 +1,64 @@ +# Copyright (c) Microsoft. All rights reserved. +from mcp.types import ListToolsResult, Tool + +from semantic_kernel.connectors.mcp import ( + MCPTool, + MCPToolParameters, +) +from semantic_kernel.connectors.mcp.mcp_server_execution_settings import ( + MCPServerExecutionSettings, +) +from semantic_kernel.functions import KernelFunction, KernelFunctionFromMethod +from semantic_kernel.functions.kernel_function_decorator import kernel_function +from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata +from semantic_kernel.utils.feature_stage_decorator import experimental + + +@experimental +async def create_function_from_mcp_server(settings: MCPServerExecutionSettings): + """Loads Function from an MCP Server to KernelFunctions.""" + async with settings.get_session() as session: + tools: ListToolsResult = await session.list_tools() + return _create_kernel_function_from_mcp_server_tools(tools, settings) + + +def _create_kernel_function_from_mcp_server_tools( + tools: ListToolsResult, settings: MCPServerExecutionSettings +) -> list[KernelFunction]: + """Loads Function from an MCP Server to KernelFunctions.""" + return [_create_kernel_function_from_mcp_server_tool(tool, settings) for tool in tools.tools] + + +def _create_kernel_function_from_mcp_server_tool(tool: Tool, settings: MCPServerExecutionSettings) -> KernelFunction: + """Generate a KernelFunction from a tool.""" + + @kernel_function(name=tool.name, description=tool.description) + async def mcp_tool_call(**kwargs): + async with settings.get_session() as session: + return await session.call_tool(tool.name, arguments=kwargs) + + # Convert MCP Object in SK Object + mcp_function: MCPTool = MCPTool.from_mcp_tool(tool) + parameters: list[KernelParameterMetadata] = [ + _generate_kernel_parameter_from_mcp_param(mcp_parameter) for mcp_parameter in mcp_function.parameters + ] + + return KernelFunctionFromMethod( + method=mcp_tool_call, + parameters=parameters, + ) + + +def _generate_kernel_parameter_from_mcp_param(property: MCPToolParameters) -> KernelParameterMetadata: + """Generate a KernelParameterMetadata from an MCP Server.""" + return KernelParameterMetadata( + name=property.name, + type_=property.type, + is_required=property.required, + default_value=property.default_value, + schema_data=property.items + if property.items is not None and isinstance(property.items, dict) + else {"type": f"{property.type}"} + if property.type + else None, + ) diff --git a/python/semantic_kernel/connectors/mcp/mcp_server_execution_settings.py b/python/semantic_kernel/connectors/mcp/mcp_server_execution_settings.py new file mode 100644 index 000000000000..03d9c0ac1d38 --- /dev/null +++ b/python/semantic_kernel/connectors/mcp/mcp_server_execution_settings.py @@ -0,0 +1,74 @@ +# Copyright (c) Microsoft. All rights reserved. +from contextlib import asynccontextmanager +from typing import Any + +from mcp import ClientSession +from mcp.client.sse import sse_client +from mcp.client.stdio import StdioServerParameters, stdio_client +from pydantic import Field + +from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError +from semantic_kernel.kernel_pydantic import KernelBaseModel + + +class MCPServerExecutionSettings(KernelBaseModel): + """MCP server settings.""" + + session: ClientSession | None = None + + @asynccontextmanager + async def get_session(self): + """Get or Open an MCP session.""" + try: + if self.session is None: + # If the session is not open, create always new one + async with self.get_mcp_client() as (read, write), ClientSession(read, write) as session: + await session.initialize() + yield session + else: + # If the session is set by the user, just yield it + yield self.session + except Exception as ex: + raise KernelPluginInvalidConfigurationError("Failed establish MCP session.") from ex + + def get_mcp_client(self): + """Get an MCP client.""" + raise NotImplementedError("This method is only needed for subclasses.") + + +class MCPStdioServerExecutionSettings(MCPServerExecutionSettings): + """MCP stdio server settings.""" + + command: str + args: list[str] = Field(default_factory=list) + env: dict[str, str] | None = None + encoding: str = "utf-8" + + def get_mcp_client(self): + """Get an MCP stdio client.""" + return stdio_client( + server=StdioServerParameters( + command=self.command, + args=self.args, + env=self.env, + encoding=self.encoding, + ) + ) + + +class MCPSseServerExecutionSettings(MCPServerExecutionSettings): + """MCP sse server settings.""" + + url: str + headers: dict[str, Any] | None = None + timeout: float = 5 + sse_read_timeout: float = 60 * 5 + + def get_mcp_client(self): + """Get an MCP SSE client.""" + return sse_client( + url=self.url, + headers=self.headers, + timeout=self.timeout, + sse_read_timeout=self.sse_read_timeout, + ) diff --git a/python/semantic_kernel/connectors/mcp/models/__init__.py b/python/semantic_kernel/connectors/mcp/models/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/python/semantic_kernel/connectors/mcp/models/mcp_tool.py b/python/semantic_kernel/connectors/mcp/models/mcp_tool.py new file mode 100644 index 000000000000..6e826b286f49 --- /dev/null +++ b/python/semantic_kernel/connectors/mcp/models/mcp_tool.py @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft. All rights reserved. +from mcp.types import Tool +from pydantic import Field + +from semantic_kernel.connectors.mcp.models.mcp_tool_parameters import MCPToolParameters +from semantic_kernel.exceptions import ServiceInvalidTypeError +from semantic_kernel.kernel_pydantic import KernelBaseModel + + +class MCPTool(KernelBaseModel): + """Semantic Kernel Class for MCP Tool.""" + + parameters: list[MCPToolParameters] = Field(default_factory=list) + + @classmethod + def from_mcp_tool(cls, tool: Tool): + """Creates an MCPFunction instance from a tool.""" + properties = tool.inputSchema.get("properties", None) + required = tool.inputSchema.get("required", None) + # Check if 'properties' is missing or not a dictionary + if properties is None or not isinstance(properties, dict): + raise ServiceInvalidTypeError("""Could not parse tool properties, + please ensure your server returns properties as a dictionary and required as an array.""") + if required is None or not isinstance(required, list): + raise ServiceInvalidTypeError("""Could not parse tool required fields, + please ensure your server returns required as an array.""") + parameters = [ + MCPToolParameters( + name=prop_name, + required=prop_name in required, + **prop_details, + ) + for prop_name, prop_details in properties.items() + ] + + return cls(parameters=parameters) diff --git a/python/semantic_kernel/connectors/mcp/models/mcp_tool_parameters.py b/python/semantic_kernel/connectors/mcp/models/mcp_tool_parameters.py new file mode 100644 index 000000000000..21b0e786fde3 --- /dev/null +++ b/python/semantic_kernel/connectors/mcp/models/mcp_tool_parameters.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft. All rights reserved. +from semantic_kernel.kernel_pydantic import KernelBaseModel + + +class MCPToolParameters(KernelBaseModel): + """Semantic Kernel Class for MCP Tool Parameters.""" + + name: str + type: str + required: bool = False + default_value: str | int | float = "" + items: dict | None = None diff --git a/python/semantic_kernel/functions/kernel_function_extension.py b/python/semantic_kernel/functions/kernel_function_extension.py index 4304c187b1d0..a50765ee67b3 100644 --- a/python/semantic_kernel/functions/kernel_function_extension.py +++ b/python/semantic_kernel/functions/kernel_function_extension.py @@ -16,8 +16,10 @@ from semantic_kernel.prompt_template.const import KERNEL_TEMPLATE_FORMAT_NAME, TEMPLATE_FORMAT_TYPES from semantic_kernel.prompt_template.prompt_template_base import PromptTemplateBase from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig +from semantic_kernel.utils.feature_stage_decorator import experimental if TYPE_CHECKING: + from semantic_kernel.connectors.mcp.mcp_server_execution_settings import MCPServerExecutionSettings from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import ( OpenAPIFunctionExecutionParameters, ) @@ -236,6 +238,34 @@ def add_plugin_from_openapi( ) ) + @experimental + async def add_plugin_from_mcp( + self, + plugin_name: str, + execution_settings: "MCPServerExecutionSettings", + description: str | None = None, + ) -> KernelPlugin: + """Add a plugins from a MCP Server. + + Args: + plugin_name: The name of the plugin + execution_settings: The execution parameters + description: The description of the plugin + + Returns: + KernelPlugin: The imported plugin + + Raises: + PluginInitializationError: if the MCP Server is not provided + """ + return self.add_plugin( + await KernelPlugin.from_mcp_server( + plugin_name=plugin_name, + execution_settings=execution_settings, + description=description, + ) + ) + def get_plugin(self, plugin_name: str) -> "KernelPlugin": """Get a plugin by name. diff --git a/python/semantic_kernel/functions/kernel_plugin.py b/python/semantic_kernel/functions/kernel_plugin.py index 8bd85e20ec12..38c2bbb6c87d 100644 --- a/python/semantic_kernel/functions/kernel_plugin.py +++ b/python/semantic_kernel/functions/kernel_plugin.py @@ -20,9 +20,11 @@ from semantic_kernel.functions.types import KERNEL_FUNCTION_TYPE from semantic_kernel.kernel_pydantic import KernelBaseModel from semantic_kernel.kernel_types import OptionalOneOrMany +from semantic_kernel.utils.feature_stage_decorator import experimental from semantic_kernel.utils.validation import PLUGIN_NAME_REGEX if TYPE_CHECKING: + from semantic_kernel.connectors.mcp.mcp_server_execution_settings import MCPServerExecutionSettings from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import ( OpenAPIFunctionExecutionParameters, ) @@ -378,6 +380,32 @@ def from_openapi( ), ) + @experimental + @classmethod + async def from_mcp_server( + cls: type[_T], + plugin_name: str, + execution_settings: "MCPServerExecutionSettings", + description: str | None = None, + ) -> _T: + """Creates a plugin from an MCP server. + + Args: + plugin_name: The name of the plugin. + execution_settings: The settings for the MCP server. + description: The description of the plugin. + + Returns: + KernelPlugin: The created plugin. + """ + from semantic_kernel.connectors.mcp.mcp_manager import create_function_from_mcp_server + + return cls( + name=plugin_name, + description=description, + functions=await create_function_from_mcp_server(settings=execution_settings), + ) + @classmethod def from_python_file( cls: type[_T], diff --git a/python/tests/assets/test_plugins/TestMCPPlugin/mcp_server.py b/python/tests/assets/test_plugins/TestMCPPlugin/mcp_server.py new file mode 100644 index 000000000000..7afe7fa2936c --- /dev/null +++ b/python/tests/assets/test_plugins/TestMCPPlugin/mcp_server.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft. All rights reserved. +from mcp.server.fastmcp import FastMCP + +# Create an MCP server +mcp = FastMCP("DemoServerForTesting", "This is a demo server for testing purposes.") + + +@mcp.tool() +def get_secret(name: str) -> int: + """Mocks Get Secret Name""" + secret_value = "Test" + return f"Secret Value : {secret_value}" + + +@mcp.tool() +def set_secret(name: str, value: str) -> int: + """Mocks Set Secret Name""" + return f"Secret Value for {name} Set" + + +if __name__ == "__main__": + # Initialize and run the server + mcp.run(transport="stdio") diff --git a/python/tests/unit/connectors/mcp/test_mcp_manager.py b/python/tests/unit/connectors/mcp/test_mcp_manager.py new file mode 100644 index 000000000000..d9a508991a72 --- /dev/null +++ b/python/tests/unit/connectors/mcp/test_mcp_manager.py @@ -0,0 +1,166 @@ +# Copyright (c) Microsoft. All rights reserved. +from unittest.mock import AsyncMock, MagicMock + +import pytest +from mcp import ClientSession +from mcp.types import ListToolsResult, Tool + +from semantic_kernel.connectors.mcp import MCPServerExecutionSettings, MCPToolParameters +from semantic_kernel.connectors.mcp.mcp_manager import ( + _create_kernel_function_from_mcp_server_tool, + _create_kernel_function_from_mcp_server_tools, + _generate_kernel_parameter_from_mcp_param, + create_function_from_mcp_server, +) +from semantic_kernel.exceptions import ServiceInvalidTypeError + + +def test_generate_kernel_parameter_from_mcp_function_no_items(): + test_param = MCPToolParameters( + name="test_param", + type="string", + required=True, + default_value="default_value", + items=None, + ) + + result = _generate_kernel_parameter_from_mcp_param(test_param) + assert result.name == "test_param" + assert result.type_ == "string" + assert result.is_required is True + assert result.default_value == "default_value" + assert result.schema_data == {"type": "string"} + + +def test_generate_kernel_parameter_from_mcp_function_items(): + test_param = MCPToolParameters( + name="test_param", + type="string", + required=True, + default_value="default_value", + items={"type": "array", "items": {"type": "string"}}, + ) + + result = _generate_kernel_parameter_from_mcp_param(test_param) + assert result.name == "test_param" + assert result.type_ == "string" + assert result.is_required is True + assert result.default_value == "default_value" + assert result.schema_data == {"type": "array", "items": {"type": "string"}} + + +def test_create_kernel_function_from_mcp_server_tool_wrong_schema(): + test_tool = Tool( + name="test_tool", + description="This is a test tool", + # Wrong schema, should contain properties & required + inputSchema={ + "param1": {"type": "string", "required": True, "default_value": "default_value"}, + "param2": {"type": "integer", "required": False}, + }, + ) + + test_settings = MCPServerExecutionSettings(session=MagicMock(spec=ClientSession)) + with pytest.raises(ServiceInvalidTypeError): + _create_kernel_function_from_mcp_server_tool(test_tool, test_settings) + + +def test_create_kernel_function_from_mcp_server_tool_missing_required(): + test_tool = Tool( + name="test_tool", + description="This is a test tool", + inputSchema={ + "properties": { + "test": {"type": "string", "default_value": "default_value"}, + "test2": {"type": "integer"}, + }, + }, + ) + + test_settings = MCPServerExecutionSettings(session=MagicMock(spec=ClientSession)) + with pytest.raises(ServiceInvalidTypeError): + _create_kernel_function_from_mcp_server_tool(test_tool, test_settings) + + +def test_create_kernel_function_from_mcp_server_tool(): + test_tool = Tool( + name="test_tool", + description="This is a test tool", + inputSchema={ + "properties": { + "test": {"type": "string", "default_value": "default_value"}, + "test2": {"type": "integer"}, + }, + "required": ["test"], + }, + ) + + test_settings = MCPServerExecutionSettings(session=MagicMock(spec=ClientSession)) + result = _create_kernel_function_from_mcp_server_tool(test_tool, test_settings) + assert result.name == "test_tool" + assert result.description == "This is a test tool" + assert len(result.parameters) == 2 + assert result.parameters[0].name == "test" + assert result.parameters[0].type_ == "string" + assert result.parameters[0].is_required is True + assert result.parameters[0].default_value == "default_value" + assert result.parameters[0].schema_data == {"type": "string"} + + +def test_create_kernel_function_from_mcp_server_tools(): + test_tool = Tool( + name="test_tool", + description="This is a test tool", + inputSchema={ + "properties": { + "test": {"type": "string", "default_value": "default_value"}, + "test2": {"type": "integer"}, + }, + "required": ["test"], + }, + ) + test_list_tools_result = ListToolsResult( + tools=[test_tool, test_tool], + ) + test_settings = MCPServerExecutionSettings(session=MagicMock(spec=ClientSession)) + + results = _create_kernel_function_from_mcp_server_tools(test_list_tools_result, test_settings) + assert len(results) == 2 + assert results[0].name == "test_tool" + assert results[0].parameters[0].name == "test" + assert results[0].parameters[0].type_ == "string" + assert results[0].parameters[0].is_required is True + assert results[0].parameters[0].default_value == "default_value" + assert results[0].parameters[0].schema_data == {"type": "string"} + + +@pytest.mark.asyncio +async def test_create_function_from_mcp_server(): + test_tool = Tool( + name="test_tool", + description="This is a test tool", + inputSchema={ + "properties": { + "test": {"type": "string", "default_value": "default_value"}, + "test2": {"type": "integer"}, + }, + "required": ["test"], + }, + ) + test_list_tools_result = ListToolsResult( + tools=[test_tool, test_tool], + ) + # Mock the ServerSession + mock_session = MagicMock(spec=ClientSession) + mock_session.list_tools = AsyncMock(return_value=test_list_tools_result) + settings = MCPServerExecutionSettings(session=mock_session) + + results = await create_function_from_mcp_server(settings=settings) + + assert len(results) == 2 + assert results[0].name == "test_tool" + assert results[0].parameters[0].name == "test" + assert results[0].parameters[0].type_ == "string" + assert results[0].parameters[0].is_required is True + assert results[0].parameters[0].default_value == "default_value" + assert results[0].parameters[0].schema_data == {"type": "string"} diff --git a/python/tests/unit/connectors/mcp/test_mcp_server_execution_settings.py b/python/tests/unit/connectors/mcp/test_mcp_server_execution_settings.py new file mode 100644 index 000000000000..9577d4cfadb4 --- /dev/null +++ b/python/tests/unit/connectors/mcp/test_mcp_server_execution_settings.py @@ -0,0 +1,102 @@ +# Copyright (c) Microsoft. All rights reserved. +from unittest.mock import MagicMock, patch + +import pytest +from mcp import ClientSession + +from semantic_kernel.connectors.mcp.mcp_server_execution_settings import ( + MCPServerExecutionSettings, + MCPSseServerExecutionSettings, + MCPStdioServerExecutionSettings, +) +from semantic_kernel.exceptions.kernel_exceptions import KernelPluginInvalidConfigurationError + + +@pytest.mark.asyncio +async def test_mcp_client_session_settings_initialize(): + # Test if Client can insert it's own Session + mock_session = MagicMock(spec=ClientSession) + settings = MCPServerExecutionSettings(session=mock_session) + async with settings.get_session() as session: + assert session is mock_session + + +@pytest.mark.asyncio +async def test_mcp_sse_server_settings_initialize_session(): + # Patch both the `ClientSession` and `sse_client` independently + with ( + patch("semantic_kernel.connectors.mcp.mcp_server_execution_settings.ClientSession") as mock_client_session, + patch("semantic_kernel.connectors.mcp.mcp_server_execution_settings.sse_client") as mock_sse_client, + ): + mock_read = MagicMock() + mock_write = MagicMock() + + mock_generator = MagicMock() + # Make the mock_sse_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_sse_client return an AsyncMock for the context manager + mock_sse_client.return_value = mock_generator + + settings = MCPSseServerExecutionSettings(url="http://localhost:8080/sse") + + # Test the `get_session` method with ClientSession mock + async with settings.get_session() as session: + assert session == mock_client_session + + +@pytest.mark.asyncio +async def test_mcp_stdio_server_settings_initialize_session(): + # Patch both the `ClientSession` and `sse_client` independently + with ( + patch("semantic_kernel.connectors.mcp.mcp_server_execution_settings.ClientSession") as mock_client_session, + patch("semantic_kernel.connectors.mcp.mcp_server_execution_settings.stdio_client") as mock_stdio_client, + ): + mock_read = MagicMock() + mock_write = MagicMock() + + mock_generator = MagicMock() + # Make the mock_sse_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_sse_client return an AsyncMock for the context manager + mock_stdio_client.return_value = mock_generator + + settings = MCPStdioServerExecutionSettings( + command="echo", + args=["Hello"], + ) + + # Test the `get_session` method with ClientSession mock + async with settings.get_session() as session: + assert session == mock_client_session + + +@pytest.mark.asyncio +async def test_mcp_stdio_server_settings_failed_initialize_session(): + # Patch both the `ClientSession` and `stdio_client` independently + with ( + patch("semantic_kernel.connectors.mcp.mcp_server_execution_settings.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 + + settings = MCPStdioServerExecutionSettings( + command="echo", + args=["Hello"], + ) + + # Test the `get_session` method with ClientSession mock and expect an exception + with pytest.raises(KernelPluginInvalidConfigurationError): + async with settings.get_session(): + pass diff --git a/python/tests/unit/functions/test_kernel_plugins.py b/python/tests/unit/functions/test_kernel_plugins.py index cdcae62915b2..a498eef30bac 100644 --- a/python/tests/unit/functions/test_kernel_plugins.py +++ b/python/tests/unit/functions/test_kernel_plugins.py @@ -8,6 +8,7 @@ from pytest import raises from semantic_kernel.connectors.ai import PromptExecutionSettings +from semantic_kernel.connectors.mcp.mcp_server_execution_settings import MCPStdioServerExecutionSettings from semantic_kernel.connectors.openapi_plugin.openapi_parser import OpenApiParser from semantic_kernel.exceptions.function_exceptions import PluginInitializationError from semantic_kernel.functions import kernel_function @@ -506,6 +507,26 @@ def test_from_openapi(): assert plugin.functions.get("SetSecret") is not None +@pytest.mark.asyncio +async def test_from_mcp(): + mcp_server_path = os.path.join(os.path.dirname(__file__), "../../assets/test_plugins", "TestMCPPlugin") + mcp_server_file = "mcp_server.py" + settings = MCPStdioServerExecutionSettings( + command="uv", + args=["--directory", mcp_server_path, "run", mcp_server_file], + ) + + plugin = await KernelPlugin.from_mcp_server( + plugin_name="TestMCPPlugin", + execution_settings=settings, + ) + + assert plugin is not None + assert plugin.name == "TestMCPPlugin" + assert plugin.functions.get("get_secret") is not None + assert plugin.functions.get("set_secret") is not None + + def test_custom_spec_from_openapi(): openapi_spec_file = os.path.join( os.path.dirname(__file__), "../../assets/test_plugins", "TestOpenAPIPlugin", "akv-openapi.yaml" diff --git a/python/tests/unit/kernel/test_kernel.py b/python/tests/unit/kernel/test_kernel.py index f99df0696595..ff6daf3080a9 100644 --- a/python/tests/unit/kernel/test_kernel.py +++ b/python/tests/unit/kernel/test_kernel.py @@ -12,6 +12,7 @@ from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings +from semantic_kernel.connectors.mcp.mcp_server_execution_settings import MCPStdioServerExecutionSettings from semantic_kernel.const import METADATA_EXCEPTION_KEY from semantic_kernel.contents import ChatMessageContent from semantic_kernel.contents.chat_history import ChatHistory @@ -616,6 +617,27 @@ def test_import_plugin_from_openapi(kernel: Kernel): assert plugin.functions.get("SetSecret") is not None +@pytest.mark.asyncio +async def test_import_plugin_from_mcp(kernel: Kernel): + mcp_server_path = os.path.join(os.path.dirname(__file__), "../../assets/test_plugins", "TestMCPPlugin") + mcp_server_file = "mcp_server.py" + settings = MCPStdioServerExecutionSettings( + command="uv", + args=["--directory", mcp_server_path, "run", mcp_server_file], + ) + + await kernel.add_plugin_from_mcp( + plugin_name="TestMCPPlugin", + execution_settings=settings, + ) + + plugin = kernel.get_plugin(plugin_name="TestMCPPlugin") + assert plugin is not None + assert plugin.name == "TestMCPPlugin" + assert plugin.functions.get("get_secret") is not None + assert plugin.functions.get("set_secret") is not None + + def test_get_plugin(kernel: Kernel): kernel.add_plugin(KernelPlugin(name="TestPlugin")) plugin = kernel.get_plugin("TestPlugin") diff --git a/python/uv.lock b/python/uv.lock index e499c5b56a63..a6249af13102 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -5,18 +5,18 @@ resolution-markers = [ "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and sys_platform == 'darwin'", "python_full_version == '3.12.*' and sys_platform == 'darwin'", - "python_full_version >= '4.0' and sys_platform == 'darwin'", "python_full_version >= '3.13' and python_full_version < '4.0' and sys_platform == 'darwin'", + "python_full_version >= '4.0' and sys_platform == 'darwin'", "python_full_version < '3.11' and sys_platform == 'linux'", "python_full_version == '3.11.*' and sys_platform == 'linux'", "python_full_version == '3.12.*' and sys_platform == 'linux'", - "python_full_version >= '4.0' and sys_platform == 'linux'", "python_full_version >= '3.13' and python_full_version < '4.0' and sys_platform == 'linux'", + "python_full_version >= '4.0' and sys_platform == 'linux'", "python_full_version < '3.11' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.12.*' and sys_platform == 'win32'", - "python_full_version >= '4.0' and sys_platform == 'win32'", "python_full_version >= '3.13' and python_full_version < '4.0' and sys_platform == 'win32'", + "python_full_version >= '4.0' and sys_platform == 'win32'", ] supported-markers = [ "sys_platform == 'darwin'", @@ -560,30 +560,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.37.18" +version = "1.37.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "jmespath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "s3transfer", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/42/2b102f999c76614e55afd8a8c2392c35ce2f390cdeb78007aba029cd1171/boto3-1.37.18.tar.gz", hash = "sha256:9b272268794172b0b8bb9fb1f3c470c3b6c0ffb92fbd4882465cc740e40fbdcd", size = 111358 } +sdist = { url = "https://files.pythonhosted.org/packages/c2/03/43244d4c6b67f34a979d2805ebb4f63c29b9aef3683ad179470fea52a5f3/boto3-1.37.19.tar.gz", hash = "sha256:c69c90500f18fd72d782d1612170b7d3db9a98ed51a4da3bebe38e693497ebf8", size = 111363 } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/94/dccc4dd874cf455c8ea6dfb4c43a224632c03c3f503438aa99021759a097/boto3-1.37.18-py3-none-any.whl", hash = "sha256:1545c943f36db41853cdfdb6ff09c4eda9220dd95bd2fae76fc73091603525d1", size = 139561 }, + { url = "https://files.pythonhosted.org/packages/e6/bb/7f3d90cc732c8c2f0dc971fa910b601f3c9bbe56df518f037653baf8ade3/boto3-1.37.19-py3-none-any.whl", hash = "sha256:fbfc2c43ad686b63c8aa02aee634c269f856eed68941d8e570cc45950be52130", size = 139560 }, ] [[package]] name = "botocore" -version = "1.37.18" +version = "1.37.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/fa/a176046c74032ca3bda68c71ad544602a69be21d7ee3b199f4f2099fe4bf/botocore-1.37.18.tar.gz", hash = "sha256:99e8eefd5df6347ead15df07ce55f4e62a51ea7b54de1127522a08597923b726", size = 13667977 } +sdist = { url = "https://files.pythonhosted.org/packages/a5/4a/cf22a677045a02cf769d8126ce25572695508e4bd5d7f6fe984dc5d23c76/botocore-1.37.19.tar.gz", hash = "sha256:eadcdc37de09df25cf1e62e8106660c61f60a68e984acfc1a8d43fb6267e53b8", size = 13667634 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/fd/059e57de7405b1ba93117c1b79a6daa45d6865f557b892f3cc7645836f3b/botocore-1.37.18-py3-none-any.whl", hash = "sha256:a8b97d217d82b3c4f6bcc906e264df7ebb51e2c6a62b3548a97cd173fb8759a1", size = 13428387 }, + { url = "https://files.pythonhosted.org/packages/b4/10/f2482186a83deb8fc45cf46e5455e501e0b2db9565251e66998a80b89aaf/botocore-1.37.19-py3-none-any.whl", hash = "sha256:6e1337e73a6b8146c1ec20a6a72d67e2809bd4c0af076431fe6e1561e0c89415", size = 13429649 }, ] [[package]] @@ -1245,16 +1245,16 @@ wheels = [ [[package]] name = "fastapi" -version = "0.115.11" +version = "0.115.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/28/c5d26e5860df807241909a961a37d45e10533acef95fc368066c7dd186cd/fastapi-0.115.11.tar.gz", hash = "sha256:cc81f03f688678b92600a65a5e618b93592c65005db37157147204d8924bf94f", size = 294441 } +sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/5d/4d8bbb94f0dbc22732350c06965e40740f4a92ca560e90bb566f4f73af41/fastapi-0.115.11-py3-none-any.whl", hash = "sha256:32e1541b7b74602e4ef4a0260ecaf3aadf9d4f19590bba3e1bf2ac4666aa2c64", size = 94926 }, + { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164 }, ] [[package]] @@ -1980,6 +1980,15 @@ http2 = [ { name = "h2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, +] + [[package]] name = "huggingface-hub" version = "0.29.3" @@ -2500,6 +2509,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899 }, ] +[[package]] +name = "mcp" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "httpx-sse", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "sse-starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/c9/c55764824e893fdebe777ac7223200986a275c3191dba9169f8eb6d7c978/mcp-1.5.0.tar.gz", hash = "sha256:5b2766c05e68e01a2034875e250139839498c61792163a7b221fc170c12f5aa9", size = 159128 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d1/3ff566ecf322077d861f1a68a1ff025cad337417bd66ad22a7c6f7dfcfaf/mcp-1.5.0-py3-none-any.whl", hash = "sha256:51c3f35ce93cb702f7513c12406bbea9665ef75a08db909200b07da9db641527", size = 73734 }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -3173,7 +3201,7 @@ wheels = [ [[package]] name = "openai" -version = "1.68.2" +version = "1.67.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3185,9 +3213,9 @@ dependencies = [ { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/6b/6b002d5d38794645437ae3ddb42083059d556558493408d39a0fcea608bc/openai-1.68.2.tar.gz", hash = "sha256:b720f0a95a1dbe1429c0d9bb62096a0d98057bcda82516f6e8af10284bdd5b19", size = 413429 } +sdist = { url = "https://files.pythonhosted.org/packages/a7/63/6fd027fa4cb7c3b6bee4c3150f44803b3a7e4335f0b6e49e83a0c51c321b/openai-1.67.0.tar.gz", hash = "sha256:3b386a866396daa4bf80e05a891c50a7746ecd7863b8a27423b62136e3b8f6bc", size = 403596 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/34/cebce15f64eb4a3d609a83ac3568d43005cc9a1cba9d7fde5590fd415423/openai-1.68.2-py3-none-any.whl", hash = "sha256:24484cb5c9a33b58576fdc5acf0e5f92603024a4e39d0b99793dfa1eb14c2b36", size = 606073 }, + { url = "https://files.pythonhosted.org/packages/42/de/b42ddabe211411645105ae99ad93f4f3984f53be7ced2ad441378c27f62e/openai-1.67.0-py3-none-any.whl", hash = "sha256:dbbb144f38739fc0e1d951bc67864647fca0b9ffa05aef6b70eeea9f71d79663", size = 580168 }, ] [[package]] @@ -3379,62 +3407,64 @@ wheels = [ [[package]] name = "orjson" -version = "3.10.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/5dea21763eeff8c1590076918a446ea3d6140743e0e36f58f369928ed0f4/orjson-3.10.15.tar.gz", hash = "sha256:05ca7fe452a2e9d8d9d706a2984c95b9c2ebc5db417ce0b7a49b91d50642a23e", size = 5282482 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/09/e5ff18ad009e6f97eb7edc5f67ef98b3ce0c189da9c3eaca1f9587cd4c61/orjson-3.10.15-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:552c883d03ad185f720d0c09583ebde257e41b9521b74ff40e08b7dec4559c04", size = 249532 }, - { url = "https://files.pythonhosted.org/packages/bd/b8/a75883301fe332bd433d9b0ded7d2bb706ccac679602c3516984f8814fb5/orjson-3.10.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e3e8d438d02e4854f70bfdc03a6bcdb697358dbaa6bcd19cbe24d24ece1f8", size = 125229 }, - { url = "https://files.pythonhosted.org/packages/83/4b/22f053e7a364cc9c685be203b1e40fc5f2b3f164a9b2284547504eec682e/orjson-3.10.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c2c79fa308e6edb0ffab0a31fd75a7841bf2a79a20ef08a3c6e3b26814c8ca8", size = 150148 }, - { url = "https://files.pythonhosted.org/packages/63/64/1b54fc75ca328b57dd810541a4035fe48c12a161d466e3cf5b11a8c25649/orjson-3.10.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cb85490aa6bf98abd20607ab5c8324c0acb48d6da7863a51be48505646c814", size = 139748 }, - { url = "https://files.pythonhosted.org/packages/5e/ff/ff0c5da781807bb0a5acd789d9a7fbcb57f7b0c6e1916595da1f5ce69f3c/orjson-3.10.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:763dadac05e4e9d2bc14938a45a2d0560549561287d41c465d3c58aec818b164", size = 154559 }, - { url = "https://files.pythonhosted.org/packages/4e/9a/11e2974383384ace8495810d4a2ebef5f55aacfc97b333b65e789c9d362d/orjson-3.10.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a330b9b4734f09a623f74a7490db713695e13b67c959713b78369f26b3dee6bf", size = 130349 }, - { url = "https://files.pythonhosted.org/packages/2d/c4/dd9583aea6aefee1b64d3aed13f51d2aadb014028bc929fe52936ec5091f/orjson-3.10.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a61a4622b7ff861f019974f73d8165be1bd9a0855e1cad18ee167acacabeb061", size = 138514 }, - { url = "https://files.pythonhosted.org/packages/53/3e/dcf1729230654f5c5594fc752de1f43dcf67e055ac0d300c8cdb1309269a/orjson-3.10.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acd271247691574416b3228db667b84775c497b245fa275c6ab90dc1ffbbd2b3", size = 130940 }, - { url = "https://files.pythonhosted.org/packages/e8/2b/b9759fe704789937705c8a56a03f6c03e50dff7df87d65cba9a20fec5282/orjson-3.10.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4759b109c37f635aa5c5cc93a1b26927bfde24b254bcc0e1149a9fada253d2d", size = 414713 }, - { url = "https://files.pythonhosted.org/packages/a7/6b/b9dfdbd4b6e20a59238319eb203ae07c3f6abf07eef909169b7a37ae3bba/orjson-3.10.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9e992fd5cfb8b9f00bfad2fd7a05a4299db2bbe92e6440d9dd2fab27655b3182", size = 141028 }, - { url = "https://files.pythonhosted.org/packages/7c/b5/40f5bbea619c7caf75eb4d652a9821875a8ed04acc45fe3d3ef054ca69fb/orjson-3.10.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f95fb363d79366af56c3f26b71df40b9a583b07bbaaf5b317407c4d58497852e", size = 129715 }, - { url = "https://files.pythonhosted.org/packages/38/60/2272514061cbdf4d672edbca6e59c7e01cd1c706e881427d88f3c3e79761/orjson-3.10.15-cp310-cp310-win32.whl", hash = "sha256:f9875f5fea7492da8ec2444839dcc439b0ef298978f311103d0b7dfd775898ab", size = 142473 }, - { url = "https://files.pythonhosted.org/packages/11/5d/be1490ff7eafe7fef890eb4527cf5bcd8cfd6117f3efe42a3249ec847b60/orjson-3.10.15-cp310-cp310-win_amd64.whl", hash = "sha256:17085a6aa91e1cd70ca8533989a18b5433e15d29c574582f76f821737c8d5806", size = 133564 }, - { url = "https://files.pythonhosted.org/packages/7a/a2/21b25ce4a2c71dbb90948ee81bd7a42b4fbfc63162e57faf83157d5540ae/orjson-3.10.15-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c4cc83960ab79a4031f3119cc4b1a1c627a3dc09df125b27c4201dff2af7eaa6", size = 249533 }, - { url = "https://files.pythonhosted.org/packages/b2/85/2076fc12d8225698a51278009726750c9c65c846eda741e77e1761cfef33/orjson-3.10.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ddbeef2481d895ab8be5185f2432c334d6dec1f5d1933a9c83014d188e102cef", size = 125230 }, - { url = "https://files.pythonhosted.org/packages/06/df/a85a7955f11274191eccf559e8481b2be74a7c6d43075d0a9506aa80284d/orjson-3.10.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e590a0477b23ecd5b0ac865b1b907b01b3c5535f5e8a8f6ab0e503efb896334", size = 150148 }, - { url = "https://files.pythonhosted.org/packages/37/b3/94c55625a29b8767c0eed194cb000b3787e3c23b4cdd13be17bae6ccbb4b/orjson-3.10.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6be38bd103d2fd9bdfa31c2720b23b5d47c6796bcb1d1b598e3924441b4298d", size = 139749 }, - { url = "https://files.pythonhosted.org/packages/53/ba/c608b1e719971e8ddac2379f290404c2e914cf8e976369bae3cad88768b1/orjson-3.10.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ff4f6edb1578960ed628a3b998fa54d78d9bb3e2eb2cfc5c2a09732431c678d0", size = 154558 }, - { url = "https://files.pythonhosted.org/packages/b2/c4/c1fb835bb23ad788a39aa9ebb8821d51b1c03588d9a9e4ca7de5b354fdd5/orjson-3.10.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0482b21d0462eddd67e7fce10b89e0b6ac56570424662b685a0d6fccf581e13", size = 130349 }, - { url = "https://files.pythonhosted.org/packages/78/14/bb2b48b26ab3c570b284eb2157d98c1ef331a8397f6c8bd983b270467f5c/orjson-3.10.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bb5cc3527036ae3d98b65e37b7986a918955f85332c1ee07f9d3f82f3a6899b5", size = 138513 }, - { url = "https://files.pythonhosted.org/packages/4a/97/d5b353a5fe532e92c46467aa37e637f81af8468aa894cd77d2ec8a12f99e/orjson-3.10.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d569c1c462912acdd119ccbf719cf7102ea2c67dd03b99edcb1a3048651ac96b", size = 130942 }, - { url = "https://files.pythonhosted.org/packages/b5/5d/a067bec55293cca48fea8b9928cfa84c623be0cce8141d47690e64a6ca12/orjson-3.10.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1e6d33efab6b71d67f22bf2962895d3dc6f82a6273a965fab762e64fa90dc399", size = 414717 }, - { url = "https://files.pythonhosted.org/packages/6f/9a/1485b8b05c6b4c4db172c438cf5db5dcfd10e72a9bc23c151a1137e763e0/orjson-3.10.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c33be3795e299f565681d69852ac8c1bc5c84863c0b0030b2b3468843be90388", size = 141033 }, - { url = "https://files.pythonhosted.org/packages/f8/d2/fc67523656e43a0c7eaeae9007c8b02e86076b15d591e9be11554d3d3138/orjson-3.10.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eea80037b9fae5339b214f59308ef0589fc06dc870578b7cce6d71eb2096764c", size = 129720 }, - { url = "https://files.pythonhosted.org/packages/79/42/f58c7bd4e5b54da2ce2ef0331a39ccbbaa7699b7f70206fbf06737c9ed7d/orjson-3.10.15-cp311-cp311-win32.whl", hash = "sha256:d5ac11b659fd798228a7adba3e37c010e0152b78b1982897020a8e019a94882e", size = 142473 }, - { url = "https://files.pythonhosted.org/packages/00/f8/bb60a4644287a544ec81df1699d5b965776bc9848d9029d9f9b3402ac8bb/orjson-3.10.15-cp311-cp311-win_amd64.whl", hash = "sha256:cf45e0214c593660339ef63e875f32ddd5aa3b4adc15e662cdb80dc49e194f8e", size = 133570 }, - { url = "https://files.pythonhosted.org/packages/66/85/22fe737188905a71afcc4bf7cc4c79cd7f5bbe9ed1fe0aac4ce4c33edc30/orjson-3.10.15-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9d11c0714fc85bfcf36ada1179400862da3288fc785c30e8297844c867d7505a", size = 249504 }, - { url = "https://files.pythonhosted.org/packages/48/b7/2622b29f3afebe938a0a9037e184660379797d5fd5234e5998345d7a5b43/orjson-3.10.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dba5a1e85d554e3897fa9fe6fbcff2ed32d55008973ec9a2b992bd9a65d2352d", size = 125080 }, - { url = "https://files.pythonhosted.org/packages/ce/8f/0b72a48f4403d0b88b2a41450c535b3e8989e8a2d7800659a967efc7c115/orjson-3.10.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7723ad949a0ea502df656948ddd8b392780a5beaa4c3b5f97e525191b102fff0", size = 150121 }, - { url = "https://files.pythonhosted.org/packages/06/ec/acb1a20cd49edb2000be5a0404cd43e3c8aad219f376ac8c60b870518c03/orjson-3.10.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6fd9bc64421e9fe9bd88039e7ce8e58d4fead67ca88e3a4014b143cec7684fd4", size = 139796 }, - { url = "https://files.pythonhosted.org/packages/33/e1/f7840a2ea852114b23a52a1c0b2bea0a1ea22236efbcdb876402d799c423/orjson-3.10.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dadba0e7b6594216c214ef7894c4bd5f08d7c0135f4dd0145600be4fbcc16767", size = 154636 }, - { url = "https://files.pythonhosted.org/packages/fa/da/31543337febd043b8fa80a3b67de627669b88c7b128d9ad4cc2ece005b7a/orjson-3.10.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48f59114fe318f33bbaee8ebeda696d8ccc94c9e90bc27dbe72153094e26f41", size = 130621 }, - { url = "https://files.pythonhosted.org/packages/ed/78/66115dc9afbc22496530d2139f2f4455698be444c7c2475cb48f657cefc9/orjson-3.10.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:035fb83585e0f15e076759b6fedaf0abb460d1765b6a36f48018a52858443514", size = 138516 }, - { url = "https://files.pythonhosted.org/packages/22/84/cd4f5fb5427ffcf823140957a47503076184cb1ce15bcc1165125c26c46c/orjson-3.10.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d13b7fe322d75bf84464b075eafd8e7dd9eae05649aa2a5354cfa32f43c59f17", size = 130762 }, - { url = "https://files.pythonhosted.org/packages/93/1f/67596b711ba9f56dd75d73b60089c5c92057f1130bb3a25a0f53fb9a583b/orjson-3.10.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7066b74f9f259849629e0d04db6609db4cf5b973248f455ba5d3bd58a4daaa5b", size = 414700 }, - { url = "https://files.pythonhosted.org/packages/7c/0c/6a3b3271b46443d90efb713c3e4fe83fa8cd71cda0d11a0f69a03f437c6e/orjson-3.10.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88dc3f65a026bd3175eb157fea994fca6ac7c4c8579fc5a86fc2114ad05705b7", size = 141077 }, - { url = "https://files.pythonhosted.org/packages/3b/9b/33c58e0bfc788995eccd0d525ecd6b84b40d7ed182dd0751cd4c1322ac62/orjson-3.10.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b342567e5465bd99faa559507fe45e33fc76b9fb868a63f1642c6bc0735ad02a", size = 129898 }, - { url = "https://files.pythonhosted.org/packages/01/c1/d577ecd2e9fa393366a1ea0a9267f6510d86e6c4bb1cdfb9877104cac44c/orjson-3.10.15-cp312-cp312-win32.whl", hash = "sha256:0a4f27ea5617828e6b58922fdbec67b0aa4bb844e2d363b9244c47fa2180e665", size = 142566 }, - { url = "https://files.pythonhosted.org/packages/ed/eb/a85317ee1732d1034b92d56f89f1de4d7bf7904f5c8fb9dcdd5b1c83917f/orjson-3.10.15-cp312-cp312-win_amd64.whl", hash = "sha256:ef5b87e7aa9545ddadd2309efe6824bd3dd64ac101c15dae0f2f597911d46eaa", size = 133732 }, - { url = "https://files.pythonhosted.org/packages/06/10/fe7d60b8da538e8d3d3721f08c1b7bff0491e8fa4dd3bf11a17e34f4730e/orjson-3.10.15-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bae0e6ec2b7ba6895198cd981b7cca95d1487d0147c8ed751e5632ad16f031a6", size = 249399 }, - { url = "https://files.pythonhosted.org/packages/6b/83/52c356fd3a61abd829ae7e4366a6fe8e8863c825a60d7ac5156067516edf/orjson-3.10.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f93ce145b2db1252dd86af37d4165b6faa83072b46e3995ecc95d4b2301b725a", size = 125044 }, - { url = "https://files.pythonhosted.org/packages/55/b2/d06d5901408e7ded1a74c7c20d70e3a127057a6d21355f50c90c0f337913/orjson-3.10.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c203f6f969210128af3acae0ef9ea6aab9782939f45f6fe02d05958fe761ef9", size = 150066 }, - { url = "https://files.pythonhosted.org/packages/75/8c/60c3106e08dc593a861755781c7c675a566445cc39558677d505878d879f/orjson-3.10.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8918719572d662e18b8af66aef699d8c21072e54b6c82a3f8f6404c1f5ccd5e0", size = 139737 }, - { url = "https://files.pythonhosted.org/packages/6a/8c/ae00d7d0ab8a4490b1efeb01ad4ab2f1982e69cc82490bf8093407718ff5/orjson-3.10.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f71eae9651465dff70aa80db92586ad5b92df46a9373ee55252109bb6b703307", size = 154804 }, - { url = "https://files.pythonhosted.org/packages/22/86/65dc69bd88b6dd254535310e97bc518aa50a39ef9c5a2a5d518e7a223710/orjson-3.10.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e117eb299a35f2634e25ed120c37c641398826c2f5a3d3cc39f5993b96171b9e", size = 130583 }, - { url = "https://files.pythonhosted.org/packages/bb/00/6fe01ededb05d52be42fabb13d93a36e51f1fd9be173bd95707d11a8a860/orjson-3.10.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:13242f12d295e83c2955756a574ddd6741c81e5b99f2bef8ed8d53e47a01e4b7", size = 138465 }, - { url = "https://files.pythonhosted.org/packages/db/2f/4cc151c4b471b0cdc8cb29d3eadbce5007eb0475d26fa26ed123dca93b33/orjson-3.10.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7946922ada8f3e0b7b958cc3eb22cfcf6c0df83d1fe5521b4a100103e3fa84c8", size = 130742 }, - { url = "https://files.pythonhosted.org/packages/9f/13/8a6109e4b477c518498ca37963d9c0eb1508b259725553fb53d53b20e2ea/orjson-3.10.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b7155eb1623347f0f22c38c9abdd738b287e39b9982e1da227503387b81b34ca", size = 414669 }, - { url = "https://files.pythonhosted.org/packages/22/7b/1d229d6d24644ed4d0a803de1b0e2df832032d5beda7346831c78191b5b2/orjson-3.10.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:208beedfa807c922da4e81061dafa9c8489c6328934ca2a562efa707e049e561", size = 141043 }, - { url = "https://files.pythonhosted.org/packages/cc/d3/6dc91156cf12ed86bed383bcb942d84d23304a1e57b7ab030bf60ea130d6/orjson-3.10.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eca81f83b1b8c07449e1d6ff7074e82e3fd6777e588f1a6632127f286a968825", size = 129826 }, - { url = "https://files.pythonhosted.org/packages/b3/38/c47c25b86f6996f1343be721b6ea4367bc1c8bc0fc3f6bbcd995d18cb19d/orjson-3.10.15-cp313-cp313-win32.whl", hash = "sha256:c03cd6eea1bd3b949d0d007c8d57049aa2b39bd49f58b4b2af571a5d3833d890", size = 142542 }, - { url = "https://files.pythonhosted.org/packages/27/f1/1d7ec15b20f8ce9300bc850de1e059132b88990e46cd0ccac29cbf11e4f9/orjson-3.10.15-cp313-cp313-win_amd64.whl", hash = "sha256:fd56a26a04f6ba5fb2045b0acc487a63162a958ed837648c5781e1fe3316cfbf", size = 133444 }, +version = "3.10.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/c7/03913cc4332174071950acf5b0735463e3f63760c80585ef369270c2b372/orjson-3.10.16.tar.gz", hash = "sha256:d2aaa5c495e11d17b9b93205f5fa196737ee3202f000aaebf028dc9a73750f10", size = 5410415 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/a6/22cb9b03baf167bc2d659c9e74d7580147f36e6a155e633801badfd5a74d/orjson-3.10.16-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4cb473b8e79154fa778fb56d2d73763d977be3dcc140587e07dbc545bbfc38f8", size = 249179 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/3e68cc33020a6ebd8f359b8628b69d2132cd84fea68155c33057e502ee51/orjson-3.10.16-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:622a8e85eeec1948690409a19ca1c7d9fd8ff116f4861d261e6ae2094fe59a00", size = 138510 }, + { url = "https://files.pythonhosted.org/packages/dc/12/63bee7764ce12052f7c1a1393ce7f26dc392c93081eb8754dd3dce9b7c6b/orjson-3.10.16-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c682d852d0ce77613993dc967e90e151899fe2d8e71c20e9be164080f468e370", size = 132373 }, + { url = "https://files.pythonhosted.org/packages/b3/d5/2998c2f319adcd572f2b03ba2083e8176863d1055d8d713683ddcf927b71/orjson-3.10.16-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c520ae736acd2e32df193bcff73491e64c936f3e44a2916b548da048a48b46b", size = 136774 }, + { url = "https://files.pythonhosted.org/packages/00/03/88c236ae307bd0604623204d4a835e15fbf9c75b8535c8f13ef45abd413f/orjson-3.10.16-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:134f87c76bfae00f2094d85cfab261b289b76d78c6da8a7a3b3c09d362fd1e06", size = 138030 }, + { url = "https://files.pythonhosted.org/packages/66/ba/3e256ddfeb364f98fd6ac65774844090d356158b2d1de8998db2bf984503/orjson-3.10.16-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b59afde79563e2cf37cfe62ee3b71c063fd5546c8e662d7fcfc2a3d5031a5c4c", size = 142677 }, + { url = "https://files.pythonhosted.org/packages/2c/71/73a1214bd27baa2ea5184fff4aa6193a114dfb0aa5663dad48fe63e8cd29/orjson-3.10.16-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:113602f8241daaff05d6fad25bd481d54c42d8d72ef4c831bb3ab682a54d9e15", size = 132798 }, + { url = "https://files.pythonhosted.org/packages/53/ac/0b2f41c0a1e8c095439d0fab3b33103cf41a39be8e6aa2c56298a6034259/orjson-3.10.16-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4fc0077d101f8fab4031e6554fc17b4c2ad8fdbc56ee64a727f3c95b379e31da", size = 135450 }, + { url = "https://files.pythonhosted.org/packages/d9/ca/7524c7b0bc815d426ca134dab54cad519802287b808a3846b047a5b2b7a3/orjson-3.10.16-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:9c6bf6ff180cd69e93f3f50380224218cfab79953a868ea3908430bcfaf9cb5e", size = 412356 }, + { url = "https://files.pythonhosted.org/packages/05/1d/3ae2367c255276bf16ff7e1b210dd0af18bc8da20c4e4295755fc7de1268/orjson-3.10.16-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5673eadfa952f95a7cd76418ff189df11b0a9c34b1995dff43a6fdbce5d63bf4", size = 152769 }, + { url = "https://files.pythonhosted.org/packages/d3/2d/8eb10b6b1d30bb69c35feb15e5ba5ac82466cf743d562e3e8047540efd2f/orjson-3.10.16-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5fe638a423d852b0ae1e1a79895851696cb0d9fa0946fdbfd5da5072d9bb9551", size = 137223 }, + { url = "https://files.pythonhosted.org/packages/47/42/f043717930cb2de5fbebe47f308f101bed9ec2b3580b1f99c8284b2f5fe8/orjson-3.10.16-cp310-cp310-win32.whl", hash = "sha256:33af58f479b3c6435ab8f8b57999874b4b40c804c7a36b5cc6b54d8f28e1d3dd", size = 141734 }, + { url = "https://files.pythonhosted.org/packages/67/99/795ad7282b425b9fddcfb8a31bded5dcf84dba78ecb1e7ae716e84e794da/orjson-3.10.16-cp310-cp310-win_amd64.whl", hash = "sha256:0338356b3f56d71293c583350af26f053017071836b07e064e92819ecf1aa055", size = 133779 }, + { url = "https://files.pythonhosted.org/packages/97/29/43f91a5512b5d2535594438eb41c5357865fd5e64dec745d90a588820c75/orjson-3.10.16-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:44fcbe1a1884f8bc9e2e863168b0f84230c3d634afe41c678637d2728ea8e739", size = 249180 }, + { url = "https://files.pythonhosted.org/packages/0c/36/2a72d55e266473c19a86d97b7363bb8bf558ab450f75205689a287d5ce61/orjson-3.10.16-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78177bf0a9d0192e0b34c3d78bcff7fe21d1b5d84aeb5ebdfe0dbe637b885225", size = 138510 }, + { url = "https://files.pythonhosted.org/packages/bb/ad/f86d6f55c1a68b57ff6ea7966bce5f4e5163f2e526ddb7db9fc3c2c8d1c4/orjson-3.10.16-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12824073a010a754bb27330cad21d6e9b98374f497f391b8707752b96f72e741", size = 132373 }, + { url = "https://files.pythonhosted.org/packages/5e/8b/d18f2711493a809f3082a88fda89342bc8e16767743b909cd3c34989fba3/orjson-3.10.16-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddd41007e56284e9867864aa2f29f3136bb1dd19a49ca43c0b4eda22a579cf53", size = 136773 }, + { url = "https://files.pythonhosted.org/packages/a1/dc/ce025f002f8e0749e3f057c4d773a4d4de32b7b4c1fc5a50b429e7532586/orjson-3.10.16-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0877c4d35de639645de83666458ca1f12560d9fa7aa9b25d8bb8f52f61627d14", size = 138029 }, + { url = "https://files.pythonhosted.org/packages/0e/1b/cf9df85852b91160029d9f26014230366a2b4deb8cc51fabe68e250a8c1a/orjson-3.10.16-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9a09a539e9cc3beead3e7107093b4ac176d015bec64f811afb5965fce077a03c", size = 142677 }, + { url = "https://files.pythonhosted.org/packages/92/18/5b1e1e995bffad49dc4311a0bdfd874bc6f135fd20f0e1f671adc2c9910e/orjson-3.10.16-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31b98bc9b40610fec971d9a4d67bb2ed02eec0a8ae35f8ccd2086320c28526ca", size = 132800 }, + { url = "https://files.pythonhosted.org/packages/d6/eb/467f25b580e942fcca1344adef40633b7f05ac44a65a63fc913f9a805d58/orjson-3.10.16-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0ce243f5a8739f3a18830bc62dc2e05b69a7545bafd3e3249f86668b2bcd8e50", size = 135451 }, + { url = "https://files.pythonhosted.org/packages/8d/4b/9d10888038975cb375982e9339d9495bac382d5c976c500b8d6f2c8e2e4e/orjson-3.10.16-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:64792c0025bae049b3074c6abe0cf06f23c8e9f5a445f4bab31dc5ca23dbf9e1", size = 412358 }, + { url = "https://files.pythonhosted.org/packages/3b/e2/cfbcfcc4fbe619e0ca9bdbbfccb2d62b540bbfe41e0ee77d44a628594f59/orjson-3.10.16-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ea53f7e68eec718b8e17e942f7ca56c6bd43562eb19db3f22d90d75e13f0431d", size = 152772 }, + { url = "https://files.pythonhosted.org/packages/b9/d6/627a1b00569be46173007c11dde3da4618c9bfe18409325b0e3e2a82fe29/orjson-3.10.16-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a741ba1a9488c92227711bde8c8c2b63d7d3816883268c808fbeada00400c164", size = 137225 }, + { url = "https://files.pythonhosted.org/packages/0a/7b/a73c67b505021af845b9f05c7c848793258ea141fa2058b52dd9b067c2b4/orjson-3.10.16-cp311-cp311-win32.whl", hash = "sha256:c7ed2c61bb8226384c3fdf1fb01c51b47b03e3f4536c985078cccc2fd19f1619", size = 141733 }, + { url = "https://files.pythonhosted.org/packages/f4/22/5e8217c48d68c0adbfb181e749d6a733761074e598b083c69a1383d18147/orjson-3.10.16-cp311-cp311-win_amd64.whl", hash = "sha256:cd67d8b3e0e56222a2e7b7f7da9031e30ecd1fe251c023340b9f12caca85ab60", size = 133784 }, + { url = "https://files.pythonhosted.org/packages/5d/15/67ce9d4c959c83f112542222ea3b9209c1d424231d71d74c4890ea0acd2b/orjson-3.10.16-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6d3444abbfa71ba21bb042caa4b062535b122248259fdb9deea567969140abca", size = 249325 }, + { url = "https://files.pythonhosted.org/packages/da/2c/1426b06f30a1b9ada74b6f512c1ddf9d2760f53f61cdb59efeb9ad342133/orjson-3.10.16-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:30245c08d818fdcaa48b7d5b81499b8cae09acabb216fe61ca619876b128e184", size = 133621 }, + { url = "https://files.pythonhosted.org/packages/9e/88/18d26130954bc73bee3be10f95371ea1dfb8679e0e2c46b0f6d8c6289402/orjson-3.10.16-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0ba1d0baa71bf7579a4ccdcf503e6f3098ef9542106a0eca82395898c8a500a", size = 138270 }, + { url = "https://files.pythonhosted.org/packages/4f/f9/6d8b64fcd58fae072e80ee7981be8ba0d7c26ace954e5cd1d027fc80518f/orjson-3.10.16-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb0beefa5ef3af8845f3a69ff2a4aa62529b5acec1cfe5f8a6b4141033fd46ef", size = 132346 }, + { url = "https://files.pythonhosted.org/packages/16/3f/2513fd5bc786f40cd12af569c23cae6381aeddbefeed2a98f0a666eb5d0d/orjson-3.10.16-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6daa0e1c9bf2e030e93c98394de94506f2a4d12e1e9dadd7c53d5e44d0f9628e", size = 136845 }, + { url = "https://files.pythonhosted.org/packages/6d/42/b0e7b36720f5ab722b48e8ccf06514d4f769358dd73c51abd8728ef58d0b/orjson-3.10.16-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9da9019afb21e02410ef600e56666652b73eb3e4d213a0ec919ff391a7dd52aa", size = 138078 }, + { url = "https://files.pythonhosted.org/packages/a3/a8/d220afb8a439604be74fc755dbc740bded5ed14745ca536b304ed32eb18a/orjson-3.10.16-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:daeb3a1ee17b69981d3aae30c3b4e786b0f8c9e6c71f2b48f1aef934f63f38f4", size = 142712 }, + { url = "https://files.pythonhosted.org/packages/8c/88/7e41e9883c00f84f92fe357a8371edae816d9d7ef39c67b5106960c20389/orjson-3.10.16-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80fed80eaf0e20a31942ae5d0728849862446512769692474be5e6b73123a23b", size = 133136 }, + { url = "https://files.pythonhosted.org/packages/e9/ca/61116095307ad0be828ea26093febaf59e38596d84a9c8d765c3c5e4934f/orjson-3.10.16-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73390ed838f03764540a7bdc4071fe0123914c2cc02fb6abf35182d5fd1b7a42", size = 135258 }, + { url = "https://files.pythonhosted.org/packages/dc/1b/09493cf7d801505f094c9295f79c98c1e0af2ac01c7ed8d25b30fcb19ada/orjson-3.10.16-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a22bba012a0c94ec02a7768953020ab0d3e2b884760f859176343a36c01adf87", size = 412326 }, + { url = "https://files.pythonhosted.org/packages/ea/02/125d7bbd7f7a500190ddc8ae5d2d3c39d87ed3ed28f5b37cfe76962c678d/orjson-3.10.16-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5385bbfdbc90ff5b2635b7e6bebf259652db00a92b5e3c45b616df75b9058e88", size = 152800 }, + { url = "https://files.pythonhosted.org/packages/f9/09/7658a9e3e793d5b3b00598023e0fb6935d0e7bbb8ff72311c5415a8ce677/orjson-3.10.16-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:02c6279016346e774dd92625d46c6c40db687b8a0d685aadb91e26e46cc33e1e", size = 137516 }, + { url = "https://files.pythonhosted.org/packages/29/87/32b7a4831e909d347278101a48d4cf9f3f25901b2295e7709df1651f65a1/orjson-3.10.16-cp312-cp312-win32.whl", hash = "sha256:7ca55097a11426db80f79378e873a8c51f4dde9ffc22de44850f9696b7eb0e8c", size = 141759 }, + { url = "https://files.pythonhosted.org/packages/35/ce/81a27e7b439b807bd393585271364cdddf50dc281fc57c4feef7ccb186a6/orjson-3.10.16-cp312-cp312-win_amd64.whl", hash = "sha256:86d127efdd3f9bf5f04809b70faca1e6836556ea3cc46e662b44dab3fe71f3d6", size = 133944 }, + { url = "https://files.pythonhosted.org/packages/87/b9/ff6aa28b8c86af9526160905593a2fe8d004ac7a5e592ee0b0ff71017511/orjson-3.10.16-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:148a97f7de811ba14bc6dbc4a433e0341ffd2cc285065199fb5f6a98013744bd", size = 249289 }, + { url = "https://files.pythonhosted.org/packages/6c/81/6d92a586149b52684ab8fd70f3623c91d0e6a692f30fd8c728916ab2263c/orjson-3.10.16-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:1d960c1bf0e734ea36d0adc880076de3846aaec45ffad29b78c7f1b7962516b8", size = 133640 }, + { url = "https://files.pythonhosted.org/packages/c2/88/b72443f4793d2e16039ab85d0026677932b15ab968595fb7149750d74134/orjson-3.10.16-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a318cd184d1269f68634464b12871386808dc8b7c27de8565234d25975a7a137", size = 138286 }, + { url = "https://files.pythonhosted.org/packages/c3/3c/72a22d4b28c076c4016d5a52bd644a8e4d849d3bb0373d9e377f9e3b2250/orjson-3.10.16-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df23f8df3ef9223d1d6748bea63fca55aae7da30a875700809c500a05975522b", size = 132307 }, + { url = "https://files.pythonhosted.org/packages/8a/a2/f1259561bdb6ad7061ff1b95dab082fe32758c4bc143ba8d3d70831f0a06/orjson-3.10.16-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b94dda8dd6d1378f1037d7f3f6b21db769ef911c4567cbaa962bb6dc5021cf90", size = 136739 }, + { url = "https://files.pythonhosted.org/packages/3d/af/c7583c4b34f33d8b8b90cfaab010ff18dd64e7074cc1e117a5f1eff20dcf/orjson-3.10.16-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f12970a26666a8775346003fd94347d03ccb98ab8aa063036818381acf5f523e", size = 138076 }, + { url = "https://files.pythonhosted.org/packages/d7/59/d7fc7fbdd3d4a64c2eae4fc7341a5aa39cf9549bd5e2d7f6d3c07f8b715b/orjson-3.10.16-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15a1431a245d856bd56e4d29ea0023eb4d2c8f71efe914beb3dee8ab3f0cd7fb", size = 142643 }, + { url = "https://files.pythonhosted.org/packages/92/0e/3bd8f2197d27601f16b4464ae948826da2bcf128af31230a9dbbad7ceb57/orjson-3.10.16-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c83655cfc247f399a222567d146524674a7b217af7ef8289c0ff53cfe8db09f0", size = 133168 }, + { url = "https://files.pythonhosted.org/packages/af/a8/351fd87b664b02f899f9144d2c3dc848b33ac04a5df05234cbfb9e2a7540/orjson-3.10.16-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fa59ae64cb6ddde8f09bdbf7baf933c4cd05734ad84dcf4e43b887eb24e37652", size = 135271 }, + { url = "https://files.pythonhosted.org/packages/ba/b0/a6d42a7d412d867c60c0337d95123517dd5a9370deea705ea1be0f89389e/orjson-3.10.16-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ca5426e5aacc2e9507d341bc169d8af9c3cbe88f4cd4c1cf2f87e8564730eb56", size = 412444 }, + { url = "https://files.pythonhosted.org/packages/79/ec/7572cd4e20863f60996f3f10bc0a6da64a6fd9c35954189a914cec0b7377/orjson-3.10.16-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6fd5da4edf98a400946cd3a195680de56f1e7575109b9acb9493331047157430", size = 152737 }, + { url = "https://files.pythonhosted.org/packages/a9/19/ceb9e8fed5403b2e76a8ac15f581b9d25780a3be3c9b3aa54b7777a210d5/orjson-3.10.16-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:980ecc7a53e567169282a5e0ff078393bac78320d44238da4e246d71a4e0e8f5", size = 137482 }, + { url = "https://files.pythonhosted.org/packages/1b/78/a78bb810f3786579dbbbd94768284cbe8f2fd65167cd7020260679665c17/orjson-3.10.16-cp313-cp313-win32.whl", hash = "sha256:28f79944dd006ac540a6465ebd5f8f45dfdf0948ff998eac7a908275b4c1add6", size = 141714 }, + { url = "https://files.pythonhosted.org/packages/81/9c/b66ce9245ff319df2c3278acd351a3f6145ef34b4a2d7f4b0f739368370f/orjson-3.10.16-cp313-cp313-win_amd64.whl", hash = "sha256:fe0a145e96d51971407cb8ba947e63ead2aa915db59d6631a355f5f2150b56b7", size = 133954 }, ] [[package]] @@ -4354,11 +4384,11 @@ wheels = [ [[package]] name = "pyparsing" -version = "3.2.1" +version = "3.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8b/1a/3544f4f299a47911c2ab3710f534e52fea62a633c96806995da5d25be4b2/pyparsing-3.2.1.tar.gz", hash = "sha256:61980854fd66de3a90028d679a954d5f2623e83144b5afe5ee86f43d762e5f0a", size = 1067694 } +sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/a7/c8a2d361bf89c0d9577c934ebb7421b25dc84bf3a8e3ac0a40aed9acc547/pyparsing-3.2.1-py3-none-any.whl", hash = "sha256:506ff4f4386c4cec0590ec19e6302d3aedb992fdc02c761e90416f158dacf8e1", size = 107716 }, + { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120 }, ] [[package]] @@ -4404,14 +4434,14 @@ wheels = [ [[package]] name = "pytest-asyncio" -version = "0.25.3" +version = "0.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/a8/ecbc8ede70921dd2f544ab1cadd3ff3bf842af27f87bbdea774c7baa1d38/pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a", size = 54239 } +sdist = { url = "https://files.pythonhosted.org/packages/8e/c4/453c52c659521066969523e87d85d54139bbd17b78f09532fb8eb8cdb58e/pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f", size = 54156 } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467 }, + { url = "https://files.pythonhosted.org/packages/20/7f/338843f449ace853647ace35870874f69a764d251872ed1b4de9f234822c/pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0", size = 19694 }, ] [[package]] @@ -4489,11 +4519,11 @@ wheels = [ [[package]] name = "pytz" -version = "2025.1" +version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/57/df1c9157c8d5a05117e455d66fd7cf6dbc46974f832b1058ed4856785d8a/pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e", size = 319617 } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930 }, + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 }, ] [[package]] @@ -4637,21 +4667,27 @@ name = "qdrant-client" version = "1.12.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '4.0' and sys_platform == 'darwin'", + "python_full_version < '3.11' and sys_platform == 'darwin'", + "python_full_version == '3.12.*' and sys_platform == 'darwin'", "python_full_version >= '3.13' and python_full_version < '4.0' and sys_platform == 'darwin'", - "python_full_version >= '4.0' and sys_platform == 'linux'", + "python_full_version >= '4.0' and sys_platform == 'darwin'", + "python_full_version < '3.11' and sys_platform == 'linux'", + "python_full_version == '3.12.*' and sys_platform == 'linux'", "python_full_version >= '3.13' and python_full_version < '4.0' and sys_platform == 'linux'", - "python_full_version >= '4.0' and sys_platform == 'win32'", + "python_full_version >= '4.0' and sys_platform == 'linux'", + "python_full_version < '3.11' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", "python_full_version >= '3.13' and python_full_version < '4.0' and sys_platform == 'win32'", + "python_full_version >= '4.0' and sys_platform == 'win32'", ] dependencies = [ - { name = "grpcio", marker = "(python_full_version >= '3.13' and sys_platform == 'darwin') or (python_full_version >= '3.13' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform == 'win32')" }, - { name = "grpcio-tools", marker = "(python_full_version >= '3.13' and sys_platform == 'darwin') or (python_full_version >= '3.13' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform == 'win32')" }, - { name = "httpx", extra = ["http2"], marker = "(python_full_version >= '3.13' and sys_platform == 'darwin') or (python_full_version >= '3.13' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform == 'win32')" }, - { name = "numpy", marker = "(python_full_version >= '3.13' and sys_platform == 'darwin') or (python_full_version >= '3.13' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform == 'win32')" }, - { name = "portalocker", marker = "(python_full_version >= '3.13' and sys_platform == 'darwin') or (python_full_version >= '3.13' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform == 'win32')" }, - { name = "pydantic", marker = "(python_full_version >= '3.13' and sys_platform == 'darwin') or (python_full_version >= '3.13' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform == 'win32')" }, - { name = "urllib3", marker = "(python_full_version >= '3.13' and sys_platform == 'darwin') or (python_full_version >= '3.13' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform == 'win32')" }, + { name = "grpcio", marker = "(python_full_version != '3.11.*' and sys_platform == 'darwin') or (python_full_version != '3.11.*' and sys_platform == 'linux') or (python_full_version != '3.11.*' and sys_platform == 'win32')" }, + { name = "grpcio-tools", marker = "(python_full_version != '3.11.*' and sys_platform == 'darwin') or (python_full_version != '3.11.*' and sys_platform == 'linux') or (python_full_version != '3.11.*' and sys_platform == 'win32')" }, + { name = "httpx", extra = ["http2"], marker = "(python_full_version != '3.11.*' and sys_platform == 'darwin') or (python_full_version != '3.11.*' and sys_platform == 'linux') or (python_full_version != '3.11.*' and sys_platform == 'win32')" }, + { name = "numpy", marker = "(python_full_version != '3.11.*' and sys_platform == 'darwin') or (python_full_version != '3.11.*' and sys_platform == 'linux') or (python_full_version != '3.11.*' and sys_platform == 'win32')" }, + { name = "portalocker", marker = "(python_full_version != '3.11.*' and sys_platform == 'darwin') or (python_full_version != '3.11.*' and sys_platform == 'linux') or (python_full_version != '3.11.*' and sys_platform == 'win32')" }, + { name = "pydantic", marker = "(python_full_version != '3.11.*' and sys_platform == 'darwin') or (python_full_version != '3.11.*' and sys_platform == 'linux') or (python_full_version != '3.11.*' and sys_platform == 'win32')" }, + { name = "urllib3", marker = "(python_full_version != '3.11.*' and sys_platform == 'darwin') or (python_full_version != '3.11.*' and sys_platform == 'linux') or (python_full_version != '3.11.*' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/15/5e/ec560881e086f893947c8798949c72de5cfae9453fd05c2250f8dfeaa571/qdrant_client-1.12.1.tar.gz", hash = "sha256:35e8e646f75b7b883b3d2d0ee4c69c5301000bba41c82aa546e985db0f1aeb72", size = 237441 } wheels = [ @@ -4663,24 +4699,18 @@ name = "qdrant-client" version = "1.13.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.11' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and sys_platform == 'darwin'", - "python_full_version == '3.12.*' and sys_platform == 'darwin'", - "python_full_version < '3.11' and sys_platform == 'linux'", "python_full_version == '3.11.*' and sys_platform == 'linux'", - "python_full_version == '3.12.*' and sys_platform == 'linux'", - "python_full_version < '3.11' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", - "python_full_version == '3.12.*' and sys_platform == 'win32'", ] dependencies = [ - { name = "grpcio", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, - { name = "grpcio-tools", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, - { name = "httpx", extra = ["http2"], marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, - { name = "numpy", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, - { name = "portalocker", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, - { name = "pydantic", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, - { name = "urllib3", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "grpcio", marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, + { name = "grpcio-tools", marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, + { name = "httpx", extra = ["http2"], marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, + { name = "numpy", marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, + { name = "portalocker", marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, + { name = "pydantic", marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, + { name = "urllib3", marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/58/1e4acd7ff7637ed56a66e5044699e7af6067232703d0b34f05068fc6234b/qdrant_client-1.13.3.tar.gz", hash = "sha256:61ca09e07c6d7ac0dfbdeb13dca4fe5f3e08fa430cb0d74d66ef5d023a70adfc", size = 266278 } wheels = [ @@ -5228,6 +5258,9 @@ hugging-face = [ { name = "torch", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "transformers", extra = ["torch"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] +mcp = [ + { name = "mcp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] milvus = [ { name = "milvus", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "pymilvus", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -5258,8 +5291,8 @@ postgres = [ { name = "psycopg", extra = ["binary", "pool"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] qdrant = [ - { name = "qdrant-client", version = "1.12.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.13' and sys_platform == 'darwin') or (python_full_version >= '3.13' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform == 'win32')" }, - { name = "qdrant-client", version = "1.13.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, + { name = "qdrant-client", version = "1.12.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version != '3.11.*' and sys_platform == 'darwin') or (python_full_version != '3.11.*' and sys_platform == 'linux') or (python_full_version != '3.11.*' and sys_platform == 'win32')" }, + { name = "qdrant-client", version = "1.13.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version == '3.11.*' and sys_platform == 'darwin') or (python_full_version == '3.11.*' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform == 'win32')" }, ] realtime = [ { name = "aiortc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -5322,6 +5355,7 @@ requires-dist = [ { name = "google-generativeai", marker = "extra == 'google'", specifier = "~=0.8" }, { name = "ipykernel", marker = "extra == 'notebooks'", specifier = "~=6.29" }, { name = "jinja2", specifier = "~=3.1" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = "~=1.5" }, { name = "milvus", marker = "sys_platform != 'win32' and extra == 'milvus'", specifier = ">=2.3,<2.3.8" }, { name = "mistralai", marker = "extra == 'mistralai'", specifier = ">=1.2,<2.0" }, { name = "motor", marker = "extra == 'mongo'", specifier = ">=3.3.2,<3.8.0" }, @@ -5397,11 +5431,11 @@ wheels = [ [[package]] name = "setuptools" -version = "77.0.3" +version = "78.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/ed/7101d53811fd359333583330ff976e5177c5e871ca8b909d1d6c30553aa3/setuptools-77.0.3.tar.gz", hash = "sha256:583b361c8da8de57403743e756609670de6fb2345920e36dc5c2d914c319c945", size = 1367236 } +sdist = { url = "https://files.pythonhosted.org/packages/4c/f4/aa8d364f0dc1f33b2718938648c31202e2db5cd6479a73f0a9ca5a88372d/setuptools-78.0.2.tar.gz", hash = "sha256:137525e6afb9022f019d6e884a319017f9bf879a0d8783985d32cbc8683cab93", size = 1367747 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/07/99f2cefae815c66eb23148f15d79ec055429c38fa8986edcc712ab5f3223/setuptools-77.0.3-py3-none-any.whl", hash = "sha256:67122e78221da5cf550ddd04cf8742c8fe12094483749a792d56cd669d6cf58c", size = 1255678 }, + { url = "https://files.pythonhosted.org/packages/aa/db/2fd473dfe436ad19fda190f4079162d400402aedfcc41e048d38c0a375c6/setuptools-78.0.2-py3-none-any.whl", hash = "sha256:4a612c80e1f1d71b80e4906ce730152e8dec23df439f82731d9d0b608d7b700d", size = 1255965 }, ] [[package]] @@ -5566,6 +5600,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186 }, ] +[[package]] +name = "sse-starlette" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/a4/80d2a11af59fe75b48230846989e93979c892d3a20016b42bb44edb9e398/sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419", size = 17376 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99", size = 10120 }, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -5972,11 +6019,11 @@ wheels = [ [[package]] name = "tzdata" -version = "2025.1" +version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/0f/fa4723f22942480be4ca9527bbde8d43f6c3f2fe8412f00e7f5f6746bc8b/tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694", size = 194950 } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/dd/84f10e23edd882c6f968c21c2434fe67bd4a528967067515feca9e611e5e/tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639", size = 346762 }, + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, ] [[package]] @@ -6255,7 +6302,7 @@ wheels = [ [[package]] name = "weaviate-client" -version = "4.11.2" +version = "4.11.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "authlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -6266,9 +6313,9 @@ dependencies = [ { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "validators", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f0/e4/99ee5d0640543285ee487b57cf830c7527610409c3c17e373384eba44811/weaviate_client-4.11.2.tar.gz", hash = "sha256:05c692e553c4da7197b0ad1c3c87ff7ee407214a6dde0ac20c612b63c65df2ac", size = 613572 } +sdist = { url = "https://files.pythonhosted.org/packages/3f/b4/5b86e73a0431e077d71aca96e135c97686e92740f304b66a40b3726be93d/weaviate_client-4.11.3.tar.gz", hash = "sha256:7ed82b04ae3bf1463d5746076cf1956e392c1a8f64440d705f9e21c5f9bb74fa", size = 613421 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/01/e95d180eb52b42c72ba7bae08cd2a0f541e91410e668299fbd1f981db0be/weaviate_client-4.11.2-py3-none-any.whl", hash = "sha256:47c9d0bbb8faa5308007ba1848e1914593a187fe2ace9cf682c0d2f1607ff1bd", size = 353977 }, + { url = "https://files.pythonhosted.org/packages/70/9e/07e2f6a7a43b230a241a30550bcb583cbeaaf838aed3c4fd5200cfcd7e1c/weaviate_client-4.11.3-py3-none-any.whl", hash = "sha256:ee64f4b55988b7951e4ce5cedb9a26ccfd5641c2a89ff962465b01eee2700b21", size = 353858 }, ] [[package]] From 56934a79958ccc12e5002492a37b60d35fbd93a6 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 1 Apr 2025 17:11:03 +0200 Subject: [PATCH 02/19] WIP MCP --- python/pyproject.toml | 2 +- .../{mcp_connector.py => mcp_as_plugin.py} | 37 +++-- python/semantic_kernel/connectors/mcp.py | 151 ++++++++++++++++++ .../connectors/mcp/__init__.py | 16 -- .../connectors/mcp/mcp_manager.py | 64 -------- .../mcp/mcp_server_execution_settings.py | 74 --------- .../connectors/mcp/models/__init__.py | 0 .../connectors/mcp/models/mcp_tool.py | 36 ----- .../mcp/models/mcp_tool_parameters.py | 12 -- .../functions/kernel_function_extension.py | 30 ---- .../functions/kernel_plugin.py | 28 ---- .../unit/connectors/mcp/test_mcp_manager.py | 15 +- .../mcp/test_mcp_server_execution_settings.py | 16 +- .../unit/functions/test_kernel_plugins.py | 4 +- python/tests/unit/kernel/test_kernel.py | 4 +- python/uv.lock | 8 +- 16 files changed, 201 insertions(+), 296 deletions(-) rename python/samples/concepts/mcp/{mcp_connector.py => mcp_as_plugin.py} (82%) create mode 100644 python/semantic_kernel/connectors/mcp.py delete mode 100644 python/semantic_kernel/connectors/mcp/__init__.py delete mode 100644 python/semantic_kernel/connectors/mcp/mcp_manager.py delete mode 100644 python/semantic_kernel/connectors/mcp/mcp_server_execution_settings.py delete mode 100644 python/semantic_kernel/connectors/mcp/models/__init__.py delete mode 100644 python/semantic_kernel/connectors/mcp/models/mcp_tool.py delete mode 100644 python/semantic_kernel/connectors/mcp/models/mcp_tool_parameters.py diff --git a/python/pyproject.toml b/python/pyproject.toml index b9fd5ec621de..a1e5eb60fb6f 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -80,7 +80,7 @@ hugging_face = [ "torch == 2.6.0" ] mcp = [ - "mcp ~= 1.5" + "mcp ~= 1.6" ] mongo = [ "pymongo >= 4.8.0, < 4.12", diff --git a/python/samples/concepts/mcp/mcp_connector.py b/python/samples/concepts/mcp/mcp_as_plugin.py similarity index 82% rename from python/samples/concepts/mcp/mcp_connector.py rename to python/samples/concepts/mcp/mcp_as_plugin.py index 9e43bd6a855c..409a27698ba3 100644 --- a/python/samples/concepts/mcp/mcp_connector.py +++ b/python/samples/concepts/mcp/mcp_as_plugin.py @@ -1,18 +1,14 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio -from typing import TYPE_CHECKING from samples.concepts.setup.chat_completion_services import Services, get_chat_completion_service_and_request_settings from semantic_kernel import Kernel from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior -from semantic_kernel.connectors.mcp.mcp_server_execution_settings import MCPStdioServerExecutionSettings +from semantic_kernel.connectors.mcp import MCPStdioClient, create_plugin_from_mcp_server from semantic_kernel.contents import ChatHistory from semantic_kernel.functions import KernelArguments -if TYPE_CHECKING: - pass - ##################################################################### # This sample demonstrates how to build a conversational chatbot # # using Semantic Kernel, featuring MCP Tools, # @@ -110,15 +106,32 @@ async def main() -> None: # Find the NPX executable in the system PATH. import shutil - execution_settings = MCPStdioServerExecutionSettings( - command=shutil.which("npx"), - args=["-y", "@modelcontextprotocol/server-github"], + github_plugin = await create_plugin_from_mcp_server( + plugin_name="GitHub", + description="Github Plugin", + client=MCPStdioClient( + command=shutil.which("npx"), + args=["-y", "@modelcontextprotocol/server-github"], + ), ) - - await kernel.add_plugin_from_mcp( - plugin_name="TestMCP", - execution_settings=execution_settings, + kernel.add_plugin(github_plugin) + file_plugin = await create_plugin_from_mcp_server( + plugin_name="File", + description="File Plugin", + client=MCPStdioClient( + command="docker", + args=[ + "run", + "-i", + "--rm", + "--mount", + "type=bind,src=/Users/edvan/Work,dst=/projects", + "mcp/filesystem", + "/projects", + ], + ), ) + kernel.add_plugin(file_plugin) print("Welcome to the chat bot!\n Type 'exit' to exit.\n") chatting = True while chatting: diff --git a/python/semantic_kernel/connectors/mcp.py b/python/semantic_kernel/connectors/mcp.py new file mode 100644 index 000000000000..39d3bde13cef --- /dev/null +++ b/python/semantic_kernel/connectors/mcp.py @@ -0,0 +1,151 @@ +# Copyright (c) Microsoft. All rights reserved. + +import logging +from abc import abstractmethod +from contextlib import _AsyncGeneratorContextManager, asynccontextmanager +from functools import partial +from typing import Any + +from mcp import ClientSession, McpError, StdioServerParameters, stdio_client +from mcp.client.sse import sse_client +from mcp.types import Tool +from pydantic import Field + +from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError, ServiceInvalidTypeError +from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException +from semantic_kernel.functions import KernelFunctionFromMethod +from semantic_kernel.functions.kernel_function_decorator import kernel_function +from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata +from semantic_kernel.functions.kernel_plugin import KernelPlugin +from semantic_kernel.kernel_pydantic import KernelBaseModel +from semantic_kernel.utils.feature_stage_decorator import experimental + +logger = logging.getLogger(__name__) + + +class MCPClient(KernelBaseModel): + """MCP server settings.""" + + session: ClientSession | None = None + + @asynccontextmanager + async def get_session(self): + """Get or Open an MCP session.""" + try: + if self.session is None: + # If the session is not open, create always new one + async with self.get_mcp_client() as (read, write), ClientSession(read, write) as session: + await session.initialize() + yield session + else: + # If the session is set by the user, just yield it + yield self.session + except Exception as ex: + raise KernelPluginInvalidConfigurationError("Failed establish MCP session.") from ex + + @abstractmethod + def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: + """Get an MCP client.""" + pass + + async def call_tool(self, tool_name: str, **kwargs: Any) -> Any: + """Call a tool with the given arguments.""" + try: + async with self.get_session() as session: + return await session.call_tool(tool_name, arguments=kwargs) + except McpError: + raise + except Exception as ex: + raise FunctionExecutionException(f"Failed to call tool '{tool_name}'.") from ex + + +class MCPStdioClient(MCPClient): + """MCP stdio client settings.""" + + command: str + args: list[str] = Field(default_factory=list) + env: dict[str, str] | None = None + encoding: str = "utf-8" + + def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: + """Get an MCP stdio client.""" + return stdio_client( + server=StdioServerParameters( + command=self.command, + args=self.args, + env=self.env, + encoding=self.encoding, + ) + ) + + +class MCPSseClient(MCPClient): + """MCP sse server settings.""" + + url: str + headers: dict[str, Any] | None = None + timeout: float = 5 + sse_read_timeout: float = 60 * 5 + + def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: + """Get an MCP SSE client.""" + return sse_client( + url=self.url, + headers=self.headers, + timeout=self.timeout, + sse_read_timeout=self.sse_read_timeout, + ) + + +@experimental +def get_parameters_from_tool(tool: Tool) -> list[KernelParameterMetadata]: + """Creates an MCPFunction instance from a tool.""" + properties = tool.inputSchema.get("properties", None) + required = tool.inputSchema.get("required", None) + # Check if 'properties' is missing or not a dictionary + if properties is None or not isinstance(properties, dict): + raise ServiceInvalidTypeError("""Could not parse tool properties, + please ensure your server returns properties as a dictionary and required as an array.""") + if required is None or not isinstance(required, list): + raise ServiceInvalidTypeError("""Could not parse tool required fields, + please ensure your server returns required as an array.""") + return [ + KernelParameterMetadata( + name=prop_name, + is_required=prop_name in required, + type=prop_details.get("type"), + default_value=prop_details.get("default", None), + schema_data=prop_details["items"] + if "items" in prop_details and prop_details["items"] is not None and isinstance(prop_details["items"], dict) + else {"type": f"{prop_details['type']}"} + if "type" in prop_details + else None, + ) + for prop_name, prop_details in properties.items() + ] + + +@experimental +async def create_plugin_from_mcp_server(plugin_name: str, description: str, client: MCPClient) -> KernelPlugin: + """Creates a KernelPlugin from an MCP server. + + Args: + plugin_name: The name of the plugin. + description: The description of the plugin. + client: The MCP client to use for communication, should be a StdioClient or SseClient. + + """ + async with client.get_session() as session: + return KernelPlugin( + name=plugin_name, + description=description, + functions=[ + KernelFunctionFromMethod( + method=kernel_function(name=tool.name, description=tool.description)( + partial(client.call_tool, tool.name) + ), + parameters=get_parameters_from_tool(tool), + ) + for tool in (await session.list_tools()).tools + ], + ) diff --git a/python/semantic_kernel/connectors/mcp/__init__.py b/python/semantic_kernel/connectors/mcp/__init__.py deleted file mode 100644 index 1d52dde8c37d..000000000000 --- a/python/semantic_kernel/connectors/mcp/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -from semantic_kernel.connectors.mcp.mcp_server_execution_settings import ( - MCPServerExecutionSettings, - MCPSseServerExecutionSettings, - MCPStdioServerExecutionSettings, -) -from semantic_kernel.connectors.mcp.models.mcp_tool import MCPTool -from semantic_kernel.connectors.mcp.models.mcp_tool_parameters import MCPToolParameters - -__all__ = [ - "MCPServerExecutionSettings", - "MCPSseServerExecutionSettings", - "MCPStdioServerExecutionSettings", - "MCPTool", - "MCPToolParameters", -] diff --git a/python/semantic_kernel/connectors/mcp/mcp_manager.py b/python/semantic_kernel/connectors/mcp/mcp_manager.py deleted file mode 100644 index bb0b0faad310..000000000000 --- a/python/semantic_kernel/connectors/mcp/mcp_manager.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -from mcp.types import ListToolsResult, Tool - -from semantic_kernel.connectors.mcp import ( - MCPTool, - MCPToolParameters, -) -from semantic_kernel.connectors.mcp.mcp_server_execution_settings import ( - MCPServerExecutionSettings, -) -from semantic_kernel.functions import KernelFunction, KernelFunctionFromMethod -from semantic_kernel.functions.kernel_function_decorator import kernel_function -from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata -from semantic_kernel.utils.feature_stage_decorator import experimental - - -@experimental -async def create_function_from_mcp_server(settings: MCPServerExecutionSettings): - """Loads Function from an MCP Server to KernelFunctions.""" - async with settings.get_session() as session: - tools: ListToolsResult = await session.list_tools() - return _create_kernel_function_from_mcp_server_tools(tools, settings) - - -def _create_kernel_function_from_mcp_server_tools( - tools: ListToolsResult, settings: MCPServerExecutionSettings -) -> list[KernelFunction]: - """Loads Function from an MCP Server to KernelFunctions.""" - return [_create_kernel_function_from_mcp_server_tool(tool, settings) for tool in tools.tools] - - -def _create_kernel_function_from_mcp_server_tool(tool: Tool, settings: MCPServerExecutionSettings) -> KernelFunction: - """Generate a KernelFunction from a tool.""" - - @kernel_function(name=tool.name, description=tool.description) - async def mcp_tool_call(**kwargs): - async with settings.get_session() as session: - return await session.call_tool(tool.name, arguments=kwargs) - - # Convert MCP Object in SK Object - mcp_function: MCPTool = MCPTool.from_mcp_tool(tool) - parameters: list[KernelParameterMetadata] = [ - _generate_kernel_parameter_from_mcp_param(mcp_parameter) for mcp_parameter in mcp_function.parameters - ] - - return KernelFunctionFromMethod( - method=mcp_tool_call, - parameters=parameters, - ) - - -def _generate_kernel_parameter_from_mcp_param(property: MCPToolParameters) -> KernelParameterMetadata: - """Generate a KernelParameterMetadata from an MCP Server.""" - return KernelParameterMetadata( - name=property.name, - type_=property.type, - is_required=property.required, - default_value=property.default_value, - schema_data=property.items - if property.items is not None and isinstance(property.items, dict) - else {"type": f"{property.type}"} - if property.type - else None, - ) diff --git a/python/semantic_kernel/connectors/mcp/mcp_server_execution_settings.py b/python/semantic_kernel/connectors/mcp/mcp_server_execution_settings.py deleted file mode 100644 index 03d9c0ac1d38..000000000000 --- a/python/semantic_kernel/connectors/mcp/mcp_server_execution_settings.py +++ /dev/null @@ -1,74 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -from contextlib import asynccontextmanager -from typing import Any - -from mcp import ClientSession -from mcp.client.sse import sse_client -from mcp.client.stdio import StdioServerParameters, stdio_client -from pydantic import Field - -from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError -from semantic_kernel.kernel_pydantic import KernelBaseModel - - -class MCPServerExecutionSettings(KernelBaseModel): - """MCP server settings.""" - - session: ClientSession | None = None - - @asynccontextmanager - async def get_session(self): - """Get or Open an MCP session.""" - try: - if self.session is None: - # If the session is not open, create always new one - async with self.get_mcp_client() as (read, write), ClientSession(read, write) as session: - await session.initialize() - yield session - else: - # If the session is set by the user, just yield it - yield self.session - except Exception as ex: - raise KernelPluginInvalidConfigurationError("Failed establish MCP session.") from ex - - def get_mcp_client(self): - """Get an MCP client.""" - raise NotImplementedError("This method is only needed for subclasses.") - - -class MCPStdioServerExecutionSettings(MCPServerExecutionSettings): - """MCP stdio server settings.""" - - command: str - args: list[str] = Field(default_factory=list) - env: dict[str, str] | None = None - encoding: str = "utf-8" - - def get_mcp_client(self): - """Get an MCP stdio client.""" - return stdio_client( - server=StdioServerParameters( - command=self.command, - args=self.args, - env=self.env, - encoding=self.encoding, - ) - ) - - -class MCPSseServerExecutionSettings(MCPServerExecutionSettings): - """MCP sse server settings.""" - - url: str - headers: dict[str, Any] | None = None - timeout: float = 5 - sse_read_timeout: float = 60 * 5 - - def get_mcp_client(self): - """Get an MCP SSE client.""" - return sse_client( - url=self.url, - headers=self.headers, - timeout=self.timeout, - sse_read_timeout=self.sse_read_timeout, - ) diff --git a/python/semantic_kernel/connectors/mcp/models/__init__.py b/python/semantic_kernel/connectors/mcp/models/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/python/semantic_kernel/connectors/mcp/models/mcp_tool.py b/python/semantic_kernel/connectors/mcp/models/mcp_tool.py deleted file mode 100644 index 6e826b286f49..000000000000 --- a/python/semantic_kernel/connectors/mcp/models/mcp_tool.py +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -from mcp.types import Tool -from pydantic import Field - -from semantic_kernel.connectors.mcp.models.mcp_tool_parameters import MCPToolParameters -from semantic_kernel.exceptions import ServiceInvalidTypeError -from semantic_kernel.kernel_pydantic import KernelBaseModel - - -class MCPTool(KernelBaseModel): - """Semantic Kernel Class for MCP Tool.""" - - parameters: list[MCPToolParameters] = Field(default_factory=list) - - @classmethod - def from_mcp_tool(cls, tool: Tool): - """Creates an MCPFunction instance from a tool.""" - properties = tool.inputSchema.get("properties", None) - required = tool.inputSchema.get("required", None) - # Check if 'properties' is missing or not a dictionary - if properties is None or not isinstance(properties, dict): - raise ServiceInvalidTypeError("""Could not parse tool properties, - please ensure your server returns properties as a dictionary and required as an array.""") - if required is None or not isinstance(required, list): - raise ServiceInvalidTypeError("""Could not parse tool required fields, - please ensure your server returns required as an array.""") - parameters = [ - MCPToolParameters( - name=prop_name, - required=prop_name in required, - **prop_details, - ) - for prop_name, prop_details in properties.items() - ] - - return cls(parameters=parameters) diff --git a/python/semantic_kernel/connectors/mcp/models/mcp_tool_parameters.py b/python/semantic_kernel/connectors/mcp/models/mcp_tool_parameters.py deleted file mode 100644 index 21b0e786fde3..000000000000 --- a/python/semantic_kernel/connectors/mcp/models/mcp_tool_parameters.py +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -from semantic_kernel.kernel_pydantic import KernelBaseModel - - -class MCPToolParameters(KernelBaseModel): - """Semantic Kernel Class for MCP Tool Parameters.""" - - name: str - type: str - required: bool = False - default_value: str | int | float = "" - items: dict | None = None diff --git a/python/semantic_kernel/functions/kernel_function_extension.py b/python/semantic_kernel/functions/kernel_function_extension.py index a50765ee67b3..4304c187b1d0 100644 --- a/python/semantic_kernel/functions/kernel_function_extension.py +++ b/python/semantic_kernel/functions/kernel_function_extension.py @@ -16,10 +16,8 @@ from semantic_kernel.prompt_template.const import KERNEL_TEMPLATE_FORMAT_NAME, TEMPLATE_FORMAT_TYPES from semantic_kernel.prompt_template.prompt_template_base import PromptTemplateBase from semantic_kernel.prompt_template.prompt_template_config import PromptTemplateConfig -from semantic_kernel.utils.feature_stage_decorator import experimental if TYPE_CHECKING: - from semantic_kernel.connectors.mcp.mcp_server_execution_settings import MCPServerExecutionSettings from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import ( OpenAPIFunctionExecutionParameters, ) @@ -238,34 +236,6 @@ def add_plugin_from_openapi( ) ) - @experimental - async def add_plugin_from_mcp( - self, - plugin_name: str, - execution_settings: "MCPServerExecutionSettings", - description: str | None = None, - ) -> KernelPlugin: - """Add a plugins from a MCP Server. - - Args: - plugin_name: The name of the plugin - execution_settings: The execution parameters - description: The description of the plugin - - Returns: - KernelPlugin: The imported plugin - - Raises: - PluginInitializationError: if the MCP Server is not provided - """ - return self.add_plugin( - await KernelPlugin.from_mcp_server( - plugin_name=plugin_name, - execution_settings=execution_settings, - description=description, - ) - ) - def get_plugin(self, plugin_name: str) -> "KernelPlugin": """Get a plugin by name. diff --git a/python/semantic_kernel/functions/kernel_plugin.py b/python/semantic_kernel/functions/kernel_plugin.py index 38c2bbb6c87d..8bd85e20ec12 100644 --- a/python/semantic_kernel/functions/kernel_plugin.py +++ b/python/semantic_kernel/functions/kernel_plugin.py @@ -20,11 +20,9 @@ from semantic_kernel.functions.types import KERNEL_FUNCTION_TYPE from semantic_kernel.kernel_pydantic import KernelBaseModel from semantic_kernel.kernel_types import OptionalOneOrMany -from semantic_kernel.utils.feature_stage_decorator import experimental from semantic_kernel.utils.validation import PLUGIN_NAME_REGEX if TYPE_CHECKING: - from semantic_kernel.connectors.mcp.mcp_server_execution_settings import MCPServerExecutionSettings from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import ( OpenAPIFunctionExecutionParameters, ) @@ -380,32 +378,6 @@ def from_openapi( ), ) - @experimental - @classmethod - async def from_mcp_server( - cls: type[_T], - plugin_name: str, - execution_settings: "MCPServerExecutionSettings", - description: str | None = None, - ) -> _T: - """Creates a plugin from an MCP server. - - Args: - plugin_name: The name of the plugin. - execution_settings: The settings for the MCP server. - description: The description of the plugin. - - Returns: - KernelPlugin: The created plugin. - """ - from semantic_kernel.connectors.mcp.mcp_manager import create_function_from_mcp_server - - return cls( - name=plugin_name, - description=description, - functions=await create_function_from_mcp_server(settings=execution_settings), - ) - @classmethod def from_python_file( cls: type[_T], diff --git a/python/tests/unit/connectors/mcp/test_mcp_manager.py b/python/tests/unit/connectors/mcp/test_mcp_manager.py index d9a508991a72..c98db5e1f356 100644 --- a/python/tests/unit/connectors/mcp/test_mcp_manager.py +++ b/python/tests/unit/connectors/mcp/test_mcp_manager.py @@ -5,8 +5,9 @@ from mcp import ClientSession from mcp.types import ListToolsResult, Tool -from semantic_kernel.connectors.mcp import MCPServerExecutionSettings, MCPToolParameters -from semantic_kernel.connectors.mcp.mcp_manager import ( +from semantic_kernel.connectors.mcp import ( + MCPClient, + MCPToolParameters, _create_kernel_function_from_mcp_server_tool, _create_kernel_function_from_mcp_server_tools, _generate_kernel_parameter_from_mcp_param, @@ -60,7 +61,7 @@ def test_create_kernel_function_from_mcp_server_tool_wrong_schema(): }, ) - test_settings = MCPServerExecutionSettings(session=MagicMock(spec=ClientSession)) + test_settings = MCPClient(session=MagicMock(spec=ClientSession)) with pytest.raises(ServiceInvalidTypeError): _create_kernel_function_from_mcp_server_tool(test_tool, test_settings) @@ -77,7 +78,7 @@ def test_create_kernel_function_from_mcp_server_tool_missing_required(): }, ) - test_settings = MCPServerExecutionSettings(session=MagicMock(spec=ClientSession)) + test_settings = MCPClient(session=MagicMock(spec=ClientSession)) with pytest.raises(ServiceInvalidTypeError): _create_kernel_function_from_mcp_server_tool(test_tool, test_settings) @@ -95,7 +96,7 @@ def test_create_kernel_function_from_mcp_server_tool(): }, ) - test_settings = MCPServerExecutionSettings(session=MagicMock(spec=ClientSession)) + test_settings = MCPClient(session=MagicMock(spec=ClientSession)) result = _create_kernel_function_from_mcp_server_tool(test_tool, test_settings) assert result.name == "test_tool" assert result.description == "This is a test tool" @@ -122,7 +123,7 @@ def test_create_kernel_function_from_mcp_server_tools(): test_list_tools_result = ListToolsResult( tools=[test_tool, test_tool], ) - test_settings = MCPServerExecutionSettings(session=MagicMock(spec=ClientSession)) + test_settings = MCPClient(session=MagicMock(spec=ClientSession)) results = _create_kernel_function_from_mcp_server_tools(test_list_tools_result, test_settings) assert len(results) == 2 @@ -153,7 +154,7 @@ async def test_create_function_from_mcp_server(): # Mock the ServerSession mock_session = MagicMock(spec=ClientSession) mock_session.list_tools = AsyncMock(return_value=test_list_tools_result) - settings = MCPServerExecutionSettings(session=mock_session) + settings = MCPClient(session=mock_session) results = await create_function_from_mcp_server(settings=settings) diff --git a/python/tests/unit/connectors/mcp/test_mcp_server_execution_settings.py b/python/tests/unit/connectors/mcp/test_mcp_server_execution_settings.py index 9577d4cfadb4..f722ba0a6b9a 100644 --- a/python/tests/unit/connectors/mcp/test_mcp_server_execution_settings.py +++ b/python/tests/unit/connectors/mcp/test_mcp_server_execution_settings.py @@ -4,10 +4,10 @@ import pytest from mcp import ClientSession -from semantic_kernel.connectors.mcp.mcp_server_execution_settings import ( - MCPServerExecutionSettings, - MCPSseServerExecutionSettings, - MCPStdioServerExecutionSettings, +from semantic_kernel.connectors.mcp import ( + MCPClient, + MCPSseClient, + MCPStdioClient, ) from semantic_kernel.exceptions.kernel_exceptions import KernelPluginInvalidConfigurationError @@ -16,7 +16,7 @@ async def test_mcp_client_session_settings_initialize(): # Test if Client can insert it's own Session mock_session = MagicMock(spec=ClientSession) - settings = MCPServerExecutionSettings(session=mock_session) + settings = MCPClient(session=mock_session) async with settings.get_session() as session: assert session is mock_session @@ -39,7 +39,7 @@ async def test_mcp_sse_server_settings_initialize_session(): # Make the mock_sse_client return an AsyncMock for the context manager mock_sse_client.return_value = mock_generator - settings = MCPSseServerExecutionSettings(url="http://localhost:8080/sse") + settings = MCPSseClient(url="http://localhost:8080/sse") # Test the `get_session` method with ClientSession mock async with settings.get_session() as session: @@ -64,7 +64,7 @@ async def test_mcp_stdio_server_settings_initialize_session(): # Make the mock_sse_client return an AsyncMock for the context manager mock_stdio_client.return_value = mock_generator - settings = MCPStdioServerExecutionSettings( + settings = MCPStdioClient( command="echo", args=["Hello"], ) @@ -91,7 +91,7 @@ async def test_mcp_stdio_server_settings_failed_initialize_session(): # Make the mock_stdio_client return an AsyncMock for the context manager mock_stdio_client.return_value = mock_generator - settings = MCPStdioServerExecutionSettings( + settings = MCPStdioClient( command="echo", args=["Hello"], ) diff --git a/python/tests/unit/functions/test_kernel_plugins.py b/python/tests/unit/functions/test_kernel_plugins.py index a498eef30bac..bd8249822012 100644 --- a/python/tests/unit/functions/test_kernel_plugins.py +++ b/python/tests/unit/functions/test_kernel_plugins.py @@ -8,7 +8,7 @@ from pytest import raises from semantic_kernel.connectors.ai import PromptExecutionSettings -from semantic_kernel.connectors.mcp.mcp_server_execution_settings import MCPStdioServerExecutionSettings +from semantic_kernel.connectors.mcp import MCPStdioClient from semantic_kernel.connectors.openapi_plugin.openapi_parser import OpenApiParser from semantic_kernel.exceptions.function_exceptions import PluginInitializationError from semantic_kernel.functions import kernel_function @@ -511,7 +511,7 @@ def test_from_openapi(): async def test_from_mcp(): mcp_server_path = os.path.join(os.path.dirname(__file__), "../../assets/test_plugins", "TestMCPPlugin") mcp_server_file = "mcp_server.py" - settings = MCPStdioServerExecutionSettings( + settings = MCPStdioClient( command="uv", args=["--directory", mcp_server_path, "run", mcp_server_file], ) diff --git a/python/tests/unit/kernel/test_kernel.py b/python/tests/unit/kernel/test_kernel.py index ff6daf3080a9..13b37ac837b8 100644 --- a/python/tests/unit/kernel/test_kernel.py +++ b/python/tests/unit/kernel/test_kernel.py @@ -12,7 +12,7 @@ from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings -from semantic_kernel.connectors.mcp.mcp_server_execution_settings import MCPStdioServerExecutionSettings +from semantic_kernel.connectors.mcp import MCPStdioClient from semantic_kernel.const import METADATA_EXCEPTION_KEY from semantic_kernel.contents import ChatMessageContent from semantic_kernel.contents.chat_history import ChatHistory @@ -621,7 +621,7 @@ def test_import_plugin_from_openapi(kernel: Kernel): async def test_import_plugin_from_mcp(kernel: Kernel): mcp_server_path = os.path.join(os.path.dirname(__file__), "../../assets/test_plugins", "TestMCPPlugin") mcp_server_file = "mcp_server.py" - settings = MCPStdioServerExecutionSettings( + settings = MCPStdioClient( command="uv", args=["--directory", mcp_server_path, "run", mcp_server_file], ) diff --git a/python/uv.lock b/python/uv.lock index a6249af13102..c13ea94982ed 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -2511,7 +2511,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.5.0" +version = "1.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2523,9 +2523,9 @@ dependencies = [ { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/c9/c55764824e893fdebe777ac7223200986a275c3191dba9169f8eb6d7c978/mcp-1.5.0.tar.gz", hash = "sha256:5b2766c05e68e01a2034875e250139839498c61792163a7b221fc170c12f5aa9", size = 159128 } +sdist = { url = "https://files.pythonhosted.org/packages/95/d2/f587cb965a56e992634bebc8611c5b579af912b74e04eb9164bd49527d21/mcp-1.6.0.tar.gz", hash = "sha256:d9324876de2c5637369f43161cd71eebfd803df5a95e46225cab8d280e366723", size = 200031 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/d1/3ff566ecf322077d861f1a68a1ff025cad337417bd66ad22a7c6f7dfcfaf/mcp-1.5.0-py3-none-any.whl", hash = "sha256:51c3f35ce93cb702f7513c12406bbea9665ef75a08db909200b07da9db641527", size = 73734 }, + { url = "https://files.pythonhosted.org/packages/10/30/20a7f33b0b884a9d14dd3aa94ff1ac9da1479fe2ad66dd9e2736075d2506/mcp-1.6.0-py3-none-any.whl", hash = "sha256:7bd24c6ea042dbec44c754f100984d186620d8b841ec30f1b19eda9b93a634d0", size = 76077 }, ] [[package]] @@ -5355,7 +5355,7 @@ requires-dist = [ { name = "google-generativeai", marker = "extra == 'google'", specifier = "~=0.8" }, { name = "ipykernel", marker = "extra == 'notebooks'", specifier = "~=6.29" }, { name = "jinja2", specifier = "~=3.1" }, - { name = "mcp", marker = "extra == 'mcp'", specifier = "~=1.5" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = "~=1.6" }, { name = "milvus", marker = "sys_platform != 'win32' and extra == 'milvus'", specifier = ">=2.3,<2.3.8" }, { name = "mistralai", marker = "extra == 'mistralai'", specifier = ">=1.2,<2.0" }, { name = "motor", marker = "extra == 'mongo'", specifier = ">=3.3.2,<3.8.0" }, From bc240ea061f261d5075035baf487ef89f7f23ebd Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 2 Apr 2025 12:25:21 +0200 Subject: [PATCH 03/19] updated MCP code and sample --- python/samples/concepts/mcp/mcp_as_plugin.py | 71 +++------- python/semantic_kernel/connectors/mcp.py | 121 +++++++++++++----- .../unit/connectors/mcp/test_mcp_manager.py | 12 +- .../mcp/test_mcp_server_execution_settings.py | 14 +- .../unit/functions/test_kernel_plugins.py | 4 +- python/tests/unit/kernel/test_kernel.py | 4 +- 6 files changed, 125 insertions(+), 101 deletions(-) diff --git a/python/samples/concepts/mcp/mcp_as_plugin.py b/python/samples/concepts/mcp/mcp_as_plugin.py index 409a27698ba3..91c84ae27352 100644 --- a/python/samples/concepts/mcp/mcp_as_plugin.py +++ b/python/samples/concepts/mcp/mcp_as_plugin.py @@ -5,17 +5,16 @@ from samples.concepts.setup.chat_completion_services import Services, get_chat_completion_service_and_request_settings from semantic_kernel import Kernel from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior -from semantic_kernel.connectors.mcp import MCPStdioClient, create_plugin_from_mcp_server +from semantic_kernel.connectors.mcp import create_plugin_from_mcp_server from semantic_kernel.contents import ChatHistory -from semantic_kernel.functions import KernelArguments -##################################################################### -# This sample demonstrates how to build a conversational chatbot # -# using Semantic Kernel, featuring MCP Tools, # -# non-streaming responses, and support for math and time plugins. # -# The chatbot is designed to interact with the user, call functions # -# as needed, and return responses. # -##################################################################### +""" +This sample demonstrates how to build a conversational chatbot +using Semantic Kernel, +it creates a Plugin from a MCP server config and adds it to the kernel. +The chatbot is designed to interact with the user, call MCP tools +as needed, and return responses. +""" # System message defining the behavior and persona of the chat bot. system_message = """ @@ -34,13 +33,6 @@ # Create and configure the kernel. kernel = Kernel() -# Define a chat function (a template for how to handle user input). -chat_function = kernel.add_function( - prompt="{{$chat_history}}{{$user_input}}", - plugin_name="ChatBot", - function_name="Chat", -) - # You can select from the following chat completion services that support function calling: # - Services.OPENAI # - Services.AZURE_OPENAI @@ -54,16 +46,13 @@ # - Services.VERTEX_AI # - Services.DEEPSEEK # Please make sure you have configured your environment correctly for the selected chat completion service. -chat_completion_service, request_settings = get_chat_completion_service_and_request_settings(Services.AZURE_OPENAI) +chat_service, settings = get_chat_completion_service_and_request_settings(Services.OPENAI) # Configure the function choice behavior. Here, we set it to Auto, where auto_invoke=True by default. # With `auto_invoke=True`, the model will automatically choose and call functions as needed. -request_settings.function_choice_behavior = FunctionChoiceBehavior.Auto(filters={"excluded_plugins": ["ChatBot"]}) +settings.function_choice_behavior = FunctionChoiceBehavior.Auto() -kernel.add_service(chat_completion_service) - -# Pass the request settings to the kernel arguments. -arguments = KernelArguments(settings=request_settings) +kernel.add_service(chat_service) # Create a chat history to store the system message, initial messages, and the conversation. history = ChatHistory() @@ -85,53 +74,25 @@ async def chat() -> bool: print("\n\nExiting chat...") return False - arguments["user_input"] = user_input - arguments["chat_history"] = history - - # Handle non-streaming responses - result = await kernel.invoke(chat_function, arguments=arguments) - - # Update the chat history with the user's input and the assistant's response + history.add_user_message(user_input) + result = await chat_service.get_chat_message_content(history, settings, kernel=kernel) if result: print(f"Mosscap:> {result}") - history.add_user_message(user_input) - history.add_message(result.value[0]) # Capture the full context of the response + history.add_message(result) return True async def main() -> None: # Make sure to have NPX installed and available in your PATH. - # Find the NPX executable in the system PATH. - import shutil - github_plugin = await create_plugin_from_mcp_server( plugin_name="GitHub", description="Github Plugin", - client=MCPStdioClient( - command=shutil.which("npx"), - args=["-y", "@modelcontextprotocol/server-github"], - ), + command="npx", + args=["-y", "@modelcontextprotocol/server-github"], ) kernel.add_plugin(github_plugin) - file_plugin = await create_plugin_from_mcp_server( - plugin_name="File", - description="File Plugin", - client=MCPStdioClient( - command="docker", - args=[ - "run", - "-i", - "--rm", - "--mount", - "type=bind,src=/Users/edvan/Work,dst=/projects", - "mcp/filesystem", - "/projects", - ], - ), - ) - kernel.add_plugin(file_plugin) print("Welcome to the chat bot!\n Type 'exit' to exit.\n") chatting = True while chatting: diff --git a/python/semantic_kernel/connectors/mcp.py b/python/semantic_kernel/connectors/mcp.py index 39d3bde13cef..cf818805727b 100644 --- a/python/semantic_kernel/connectors/mcp.py +++ b/python/semantic_kernel/connectors/mcp.py @@ -9,7 +9,7 @@ from mcp import ClientSession, McpError, StdioServerParameters, stdio_client from mcp.client.sse import sse_client from mcp.types import Tool -from pydantic import Field +from pydantic import BaseModel, ConfigDict, Field from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError, ServiceInvalidTypeError from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException @@ -17,14 +17,17 @@ from semantic_kernel.functions.kernel_function_decorator import kernel_function from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata from semantic_kernel.functions.kernel_plugin import KernelPlugin -from semantic_kernel.kernel_pydantic import KernelBaseModel from semantic_kernel.utils.feature_stage_decorator import experimental logger = logging.getLogger(__name__) -class MCPClient(KernelBaseModel): - """MCP server settings.""" +class MCPServerConfig(BaseModel): + """MCP server configuration.""" + + model_config = ConfigDict( + populate_by_name=True, arbitrary_types_allowed=True, validate_assignment=True, extra="allow" + ) session: ClientSession | None = None @@ -59,42 +62,79 @@ async def call_tool(self, tool_name: str, **kwargs: Any) -> Any: raise FunctionExecutionException(f"Failed to call tool '{tool_name}'.") from ex -class MCPStdioClient(MCPClient): - """MCP stdio client settings.""" +class MCPStdioServerConfig(MCPServerConfig): + """MCP stdio server configuration. + + The arguments are used to create a StdioServerParameters object. + Which is then used to create a stdio client. + see mcp.client.stdio.stdio_client and mcp.client.stdio.stdio_server_parameters + for more details. + + Any extra arguments passed to the constructor will be passed to the + StdioServerParameters constructor. + + Args: + command: The command to run the MCP server. + args: The arguments to pass to the command. + env: The environment variables to set for the command. + encoding: The encoding to use for the command output. + + """ command: str args: list[str] = Field(default_factory=list) env: dict[str, str] | None = None - encoding: str = "utf-8" + encoding: str | None = None def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: """Get an MCP stdio client.""" - return stdio_client( - server=StdioServerParameters( - command=self.command, - args=self.args, - env=self.env, - encoding=self.encoding, - ) - ) + args = { + "command": self.command, + "args": self.args, + "env": self.env, + } + if self.encoding: + args["encoding"] = self.encoding + if self.model_extra: + args.update(self.model_extra) + return stdio_client(server=StdioServerParameters(**args)) -class MCPSseClient(MCPClient): - """MCP sse server settings.""" +class MCPSseServerConfig(MCPServerConfig): + """MCP sse server configuration. + + The arguments are used to create a sse client. + see mcp.client.sse.sse_client for more details. + + Any extra arguments passed to the constructor will be passed to the + sse client constructor. + + Args: + url: The URL of the MCP server. + headers: The headers to send with the request. + timeout: The timeout for the request. + sse_read_timeout: The timeout for reading from the SSE stream. + + """ url: str headers: dict[str, Any] | None = None - timeout: float = 5 - sse_read_timeout: float = 60 * 5 + timeout: float | None = None + sse_read_timeout: float | None = None def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: """Get an MCP SSE client.""" - return sse_client( - url=self.url, - headers=self.headers, - timeout=self.timeout, - sse_read_timeout=self.sse_read_timeout, - ) + args: dict[str, Any] = { + "url": self.url, + "headers": self.headers, + } + if self.timeout is not None: + args["timeout"] = self.timeout + if self.sse_read_timeout is not None: + args["sse_read_timeout"] = self.sse_read_timeout + if self.model_extra: + args.update(self.model_extra) + return sse_client(**args) @experimental @@ -126,23 +166,46 @@ def get_parameters_from_tool(tool: Tool) -> list[KernelParameterMetadata]: @experimental -async def create_plugin_from_mcp_server(plugin_name: str, description: str, client: MCPClient) -> KernelPlugin: +async def create_plugin_from_mcp_server( + plugin_name: str, description: str, server_config: MCPServerConfig | None = None, **kwargs: Any +) -> KernelPlugin: """Creates a KernelPlugin from an MCP server. Args: plugin_name: The name of the plugin. description: The description of the plugin. - client: The MCP client to use for communication, should be a StdioClient or SseClient. + server_config: The MCP client to use for communication, + should be a MCPStdioServerConfig or MCPSseServerConfig. + kwargs: Any extra arguments to pass to the plugin creation. """ - async with client.get_session() as session: + if server_config is None: + if "url" in kwargs: + try: + server_config = MCPSseServerConfig(**kwargs) + except Exception as e: + raise KernelPluginInvalidConfigurationError( + f"Failed to create MCPSseServerConfig with args: {kwargs}" + ) from e + elif "command" in kwargs: + try: + server_config = MCPStdioServerConfig(**kwargs) + except Exception as e: + raise KernelPluginInvalidConfigurationError( + f"Failed to create MCPStdioServerConfig with args: {kwargs}" + ) from e + if server_config is None: + raise KernelPluginInvalidConfigurationError( + "Failed to create MCP server configuration, please provide a valid server_config or kwargs." + ) + async with server_config.get_session() as session: return KernelPlugin( name=plugin_name, description=description, functions=[ KernelFunctionFromMethod( method=kernel_function(name=tool.name, description=tool.description)( - partial(client.call_tool, tool.name) + partial(server_config.call_tool, tool.name) ), parameters=get_parameters_from_tool(tool), ) diff --git a/python/tests/unit/connectors/mcp/test_mcp_manager.py b/python/tests/unit/connectors/mcp/test_mcp_manager.py index c98db5e1f356..7e88928f801c 100644 --- a/python/tests/unit/connectors/mcp/test_mcp_manager.py +++ b/python/tests/unit/connectors/mcp/test_mcp_manager.py @@ -6,7 +6,7 @@ from mcp.types import ListToolsResult, Tool from semantic_kernel.connectors.mcp import ( - MCPClient, + MCPServerConfig, MCPToolParameters, _create_kernel_function_from_mcp_server_tool, _create_kernel_function_from_mcp_server_tools, @@ -61,7 +61,7 @@ def test_create_kernel_function_from_mcp_server_tool_wrong_schema(): }, ) - test_settings = MCPClient(session=MagicMock(spec=ClientSession)) + test_settings = MCPServerConfig(session=MagicMock(spec=ClientSession)) with pytest.raises(ServiceInvalidTypeError): _create_kernel_function_from_mcp_server_tool(test_tool, test_settings) @@ -78,7 +78,7 @@ def test_create_kernel_function_from_mcp_server_tool_missing_required(): }, ) - test_settings = MCPClient(session=MagicMock(spec=ClientSession)) + test_settings = MCPServerConfig(session=MagicMock(spec=ClientSession)) with pytest.raises(ServiceInvalidTypeError): _create_kernel_function_from_mcp_server_tool(test_tool, test_settings) @@ -96,7 +96,7 @@ def test_create_kernel_function_from_mcp_server_tool(): }, ) - test_settings = MCPClient(session=MagicMock(spec=ClientSession)) + test_settings = MCPServerConfig(session=MagicMock(spec=ClientSession)) result = _create_kernel_function_from_mcp_server_tool(test_tool, test_settings) assert result.name == "test_tool" assert result.description == "This is a test tool" @@ -123,7 +123,7 @@ def test_create_kernel_function_from_mcp_server_tools(): test_list_tools_result = ListToolsResult( tools=[test_tool, test_tool], ) - test_settings = MCPClient(session=MagicMock(spec=ClientSession)) + test_settings = MCPServerConfig(session=MagicMock(spec=ClientSession)) results = _create_kernel_function_from_mcp_server_tools(test_list_tools_result, test_settings) assert len(results) == 2 @@ -154,7 +154,7 @@ async def test_create_function_from_mcp_server(): # Mock the ServerSession mock_session = MagicMock(spec=ClientSession) mock_session.list_tools = AsyncMock(return_value=test_list_tools_result) - settings = MCPClient(session=mock_session) + settings = MCPServerConfig(session=mock_session) results = await create_function_from_mcp_server(settings=settings) diff --git a/python/tests/unit/connectors/mcp/test_mcp_server_execution_settings.py b/python/tests/unit/connectors/mcp/test_mcp_server_execution_settings.py index f722ba0a6b9a..9c2bd22d5a9e 100644 --- a/python/tests/unit/connectors/mcp/test_mcp_server_execution_settings.py +++ b/python/tests/unit/connectors/mcp/test_mcp_server_execution_settings.py @@ -5,9 +5,9 @@ from mcp import ClientSession from semantic_kernel.connectors.mcp import ( - MCPClient, - MCPSseClient, - MCPStdioClient, + MCPServerConfig, + MCPSseServerConfig, + MCPStdioServerConfig, ) from semantic_kernel.exceptions.kernel_exceptions import KernelPluginInvalidConfigurationError @@ -16,7 +16,7 @@ async def test_mcp_client_session_settings_initialize(): # Test if Client can insert it's own Session mock_session = MagicMock(spec=ClientSession) - settings = MCPClient(session=mock_session) + settings = MCPServerConfig(session=mock_session) async with settings.get_session() as session: assert session is mock_session @@ -39,7 +39,7 @@ async def test_mcp_sse_server_settings_initialize_session(): # Make the mock_sse_client return an AsyncMock for the context manager mock_sse_client.return_value = mock_generator - settings = MCPSseClient(url="http://localhost:8080/sse") + settings = MCPSseServerConfig(url="http://localhost:8080/sse") # Test the `get_session` method with ClientSession mock async with settings.get_session() as session: @@ -64,7 +64,7 @@ async def test_mcp_stdio_server_settings_initialize_session(): # Make the mock_sse_client return an AsyncMock for the context manager mock_stdio_client.return_value = mock_generator - settings = MCPStdioClient( + settings = MCPStdioServerConfig( command="echo", args=["Hello"], ) @@ -91,7 +91,7 @@ async def test_mcp_stdio_server_settings_failed_initialize_session(): # Make the mock_stdio_client return an AsyncMock for the context manager mock_stdio_client.return_value = mock_generator - settings = MCPStdioClient( + settings = MCPStdioServerConfig( command="echo", args=["Hello"], ) diff --git a/python/tests/unit/functions/test_kernel_plugins.py b/python/tests/unit/functions/test_kernel_plugins.py index bd8249822012..8a1eadab391a 100644 --- a/python/tests/unit/functions/test_kernel_plugins.py +++ b/python/tests/unit/functions/test_kernel_plugins.py @@ -8,7 +8,7 @@ from pytest import raises from semantic_kernel.connectors.ai import PromptExecutionSettings -from semantic_kernel.connectors.mcp import MCPStdioClient +from semantic_kernel.connectors.mcp import MCPStdioServerConfig from semantic_kernel.connectors.openapi_plugin.openapi_parser import OpenApiParser from semantic_kernel.exceptions.function_exceptions import PluginInitializationError from semantic_kernel.functions import kernel_function @@ -511,7 +511,7 @@ def test_from_openapi(): async def test_from_mcp(): mcp_server_path = os.path.join(os.path.dirname(__file__), "../../assets/test_plugins", "TestMCPPlugin") mcp_server_file = "mcp_server.py" - settings = MCPStdioClient( + settings = MCPStdioServerConfig( command="uv", args=["--directory", mcp_server_path, "run", mcp_server_file], ) diff --git a/python/tests/unit/kernel/test_kernel.py b/python/tests/unit/kernel/test_kernel.py index 13b37ac837b8..1ebb8c863b6e 100644 --- a/python/tests/unit/kernel/test_kernel.py +++ b/python/tests/unit/kernel/test_kernel.py @@ -12,7 +12,7 @@ from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings -from semantic_kernel.connectors.mcp import MCPStdioClient +from semantic_kernel.connectors.mcp import MCPStdioServerConfig from semantic_kernel.const import METADATA_EXCEPTION_KEY from semantic_kernel.contents import ChatMessageContent from semantic_kernel.contents.chat_history import ChatHistory @@ -621,7 +621,7 @@ def test_import_plugin_from_openapi(kernel: Kernel): async def test_import_plugin_from_mcp(kernel: Kernel): mcp_server_path = os.path.join(os.path.dirname(__file__), "../../assets/test_plugins", "TestMCPPlugin") mcp_server_file = "mcp_server.py" - settings = MCPStdioClient( + settings = MCPStdioServerConfig( command="uv", args=["--directory", mcp_server_path, "run", mcp_server_file], ) From f10b7fd15623b7b46723b0a715c299050f99631e Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 2 Apr 2025 12:26:57 +0200 Subject: [PATCH 04/19] updated docstring --- python/semantic_kernel/connectors/mcp.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/python/semantic_kernel/connectors/mcp.py b/python/semantic_kernel/connectors/mcp.py index cf818805727b..f50cc9ab473c 100644 --- a/python/semantic_kernel/connectors/mcp.py +++ b/python/semantic_kernel/connectors/mcp.py @@ -167,17 +167,24 @@ def get_parameters_from_tool(tool: Tool) -> list[KernelParameterMetadata]: @experimental async def create_plugin_from_mcp_server( - plugin_name: str, description: str, server_config: MCPServerConfig | None = None, **kwargs: Any + plugin_name: str, + description: str, + server_config: MCPServerConfig | None = None, + **kwargs: Any, ) -> KernelPlugin: - """Creates a KernelPlugin from an MCP server. + """Creates a KernelPlugin from a MCP server config. Args: plugin_name: The name of the plugin. description: The description of the plugin. server_config: The MCP client to use for communication, should be a MCPStdioServerConfig or MCPSseServerConfig. + If not supplied, it will be created from the kwargs. kwargs: Any extra arguments to pass to the plugin creation. + Returns: + KernelPlugin: The created plugin, this should then be passed to the kernel or a agent. + """ if server_config is None: if "url" in kwargs: From 194c168a75f16beefb93e90c5b79e1cd32dc731a Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 2 Apr 2025 13:53:05 +0200 Subject: [PATCH 05/19] test updates --- .../unit/connectors/mcp/test_mcp_manager.py | 167 ------------------ ..._settings.py => test_mcp_server_config.py} | 25 ++- 2 files changed, 10 insertions(+), 182 deletions(-) delete mode 100644 python/tests/unit/connectors/mcp/test_mcp_manager.py rename python/tests/unit/connectors/mcp/{test_mcp_server_execution_settings.py => test_mcp_server_config.py} (74%) diff --git a/python/tests/unit/connectors/mcp/test_mcp_manager.py b/python/tests/unit/connectors/mcp/test_mcp_manager.py deleted file mode 100644 index 7e88928f801c..000000000000 --- a/python/tests/unit/connectors/mcp/test_mcp_manager.py +++ /dev/null @@ -1,167 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -from unittest.mock import AsyncMock, MagicMock - -import pytest -from mcp import ClientSession -from mcp.types import ListToolsResult, Tool - -from semantic_kernel.connectors.mcp import ( - MCPServerConfig, - MCPToolParameters, - _create_kernel_function_from_mcp_server_tool, - _create_kernel_function_from_mcp_server_tools, - _generate_kernel_parameter_from_mcp_param, - create_function_from_mcp_server, -) -from semantic_kernel.exceptions import ServiceInvalidTypeError - - -def test_generate_kernel_parameter_from_mcp_function_no_items(): - test_param = MCPToolParameters( - name="test_param", - type="string", - required=True, - default_value="default_value", - items=None, - ) - - result = _generate_kernel_parameter_from_mcp_param(test_param) - assert result.name == "test_param" - assert result.type_ == "string" - assert result.is_required is True - assert result.default_value == "default_value" - assert result.schema_data == {"type": "string"} - - -def test_generate_kernel_parameter_from_mcp_function_items(): - test_param = MCPToolParameters( - name="test_param", - type="string", - required=True, - default_value="default_value", - items={"type": "array", "items": {"type": "string"}}, - ) - - result = _generate_kernel_parameter_from_mcp_param(test_param) - assert result.name == "test_param" - assert result.type_ == "string" - assert result.is_required is True - assert result.default_value == "default_value" - assert result.schema_data == {"type": "array", "items": {"type": "string"}} - - -def test_create_kernel_function_from_mcp_server_tool_wrong_schema(): - test_tool = Tool( - name="test_tool", - description="This is a test tool", - # Wrong schema, should contain properties & required - inputSchema={ - "param1": {"type": "string", "required": True, "default_value": "default_value"}, - "param2": {"type": "integer", "required": False}, - }, - ) - - test_settings = MCPServerConfig(session=MagicMock(spec=ClientSession)) - with pytest.raises(ServiceInvalidTypeError): - _create_kernel_function_from_mcp_server_tool(test_tool, test_settings) - - -def test_create_kernel_function_from_mcp_server_tool_missing_required(): - test_tool = Tool( - name="test_tool", - description="This is a test tool", - inputSchema={ - "properties": { - "test": {"type": "string", "default_value": "default_value"}, - "test2": {"type": "integer"}, - }, - }, - ) - - test_settings = MCPServerConfig(session=MagicMock(spec=ClientSession)) - with pytest.raises(ServiceInvalidTypeError): - _create_kernel_function_from_mcp_server_tool(test_tool, test_settings) - - -def test_create_kernel_function_from_mcp_server_tool(): - test_tool = Tool( - name="test_tool", - description="This is a test tool", - inputSchema={ - "properties": { - "test": {"type": "string", "default_value": "default_value"}, - "test2": {"type": "integer"}, - }, - "required": ["test"], - }, - ) - - test_settings = MCPServerConfig(session=MagicMock(spec=ClientSession)) - result = _create_kernel_function_from_mcp_server_tool(test_tool, test_settings) - assert result.name == "test_tool" - assert result.description == "This is a test tool" - assert len(result.parameters) == 2 - assert result.parameters[0].name == "test" - assert result.parameters[0].type_ == "string" - assert result.parameters[0].is_required is True - assert result.parameters[0].default_value == "default_value" - assert result.parameters[0].schema_data == {"type": "string"} - - -def test_create_kernel_function_from_mcp_server_tools(): - test_tool = Tool( - name="test_tool", - description="This is a test tool", - inputSchema={ - "properties": { - "test": {"type": "string", "default_value": "default_value"}, - "test2": {"type": "integer"}, - }, - "required": ["test"], - }, - ) - test_list_tools_result = ListToolsResult( - tools=[test_tool, test_tool], - ) - test_settings = MCPServerConfig(session=MagicMock(spec=ClientSession)) - - results = _create_kernel_function_from_mcp_server_tools(test_list_tools_result, test_settings) - assert len(results) == 2 - assert results[0].name == "test_tool" - assert results[0].parameters[0].name == "test" - assert results[0].parameters[0].type_ == "string" - assert results[0].parameters[0].is_required is True - assert results[0].parameters[0].default_value == "default_value" - assert results[0].parameters[0].schema_data == {"type": "string"} - - -@pytest.mark.asyncio -async def test_create_function_from_mcp_server(): - test_tool = Tool( - name="test_tool", - description="This is a test tool", - inputSchema={ - "properties": { - "test": {"type": "string", "default_value": "default_value"}, - "test2": {"type": "integer"}, - }, - "required": ["test"], - }, - ) - test_list_tools_result = ListToolsResult( - tools=[test_tool, test_tool], - ) - # Mock the ServerSession - mock_session = MagicMock(spec=ClientSession) - mock_session.list_tools = AsyncMock(return_value=test_list_tools_result) - settings = MCPServerConfig(session=mock_session) - - results = await create_function_from_mcp_server(settings=settings) - - assert len(results) == 2 - assert results[0].name == "test_tool" - assert results[0].parameters[0].name == "test" - assert results[0].parameters[0].type_ == "string" - assert results[0].parameters[0].is_required is True - assert results[0].parameters[0].default_value == "default_value" - assert results[0].parameters[0].schema_data == {"type": "string"} diff --git a/python/tests/unit/connectors/mcp/test_mcp_server_execution_settings.py b/python/tests/unit/connectors/mcp/test_mcp_server_config.py similarity index 74% rename from python/tests/unit/connectors/mcp/test_mcp_server_execution_settings.py rename to python/tests/unit/connectors/mcp/test_mcp_server_config.py index 9c2bd22d5a9e..9681ed21d8a1 100644 --- a/python/tests/unit/connectors/mcp/test_mcp_server_execution_settings.py +++ b/python/tests/unit/connectors/mcp/test_mcp_server_config.py @@ -5,28 +5,25 @@ from mcp import ClientSession from semantic_kernel.connectors.mcp import ( - MCPServerConfig, MCPSseServerConfig, MCPStdioServerConfig, ) -from semantic_kernel.exceptions.kernel_exceptions import KernelPluginInvalidConfigurationError +from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError -@pytest.mark.asyncio async def test_mcp_client_session_settings_initialize(): # Test if Client can insert it's own Session mock_session = MagicMock(spec=ClientSession) - settings = MCPServerConfig(session=mock_session) - async with settings.get_session() as session: + config = MCPSseServerConfig(session=mock_session, url="http://localhost:8080/sse") + async with config.get_session() as session: assert session is mock_session -@pytest.mark.asyncio async def test_mcp_sse_server_settings_initialize_session(): # Patch both the `ClientSession` and `sse_client` independently with ( - patch("semantic_kernel.connectors.mcp.mcp_server_execution_settings.ClientSession") as mock_client_session, - patch("semantic_kernel.connectors.mcp.mcp_server_execution_settings.sse_client") as mock_sse_client, + patch("semantic_kernel.connectors.mcp.ClientSession") as mock_client_session, + patch("semantic_kernel.connectors.mcp.sse_client") as mock_sse_client, ): mock_read = MagicMock() mock_write = MagicMock() @@ -46,22 +43,21 @@ async def test_mcp_sse_server_settings_initialize_session(): assert session == mock_client_session -@pytest.mark.asyncio async def test_mcp_stdio_server_settings_initialize_session(): # Patch both the `ClientSession` and `sse_client` independently with ( - patch("semantic_kernel.connectors.mcp.mcp_server_execution_settings.ClientSession") as mock_client_session, - patch("semantic_kernel.connectors.mcp.mcp_server_execution_settings.stdio_client") as mock_stdio_client, + patch("semantic_kernel.connectors.mcp.ClientSession") as mock_client_session, + patch("semantic_kernel.connectors.mcp.stdio_client") as mock_stdio_client, ): mock_read = MagicMock() mock_write = MagicMock() mock_generator = MagicMock() - # Make the mock_sse_client return an AsyncMock for the context manager + # 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_sse_client return an AsyncMock for the context manager + # Make the mock_stdio_client return an AsyncMock for the context manager mock_stdio_client.return_value = mock_generator settings = MCPStdioServerConfig( @@ -74,11 +70,10 @@ async def test_mcp_stdio_server_settings_initialize_session(): assert session == mock_client_session -@pytest.mark.asyncio async def test_mcp_stdio_server_settings_failed_initialize_session(): # Patch both the `ClientSession` and `stdio_client` independently with ( - patch("semantic_kernel.connectors.mcp.mcp_server_execution_settings.stdio_client") as mock_stdio_client, + patch("semantic_kernel.connectors.mcp.stdio_client") as mock_stdio_client, ): mock_read = MagicMock() mock_write = MagicMock() From ee54dc5376f1ff60496e7d7a8cbf1f8fb91521de Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 2 Apr 2025 14:40:48 +0200 Subject: [PATCH 06/19] updated tests --- python/semantic_kernel/connectors/mcp.py | 2 +- .../test_plugins/TestMCPPlugin/mcp_server.py | 13 +++--- ...{test_mcp_server_config.py => test_mcp.py} | 40 +++++++++++++++++-- .../unit/functions/test_kernel_plugins.py | 21 ---------- 4 files changed, 44 insertions(+), 32 deletions(-) rename python/tests/unit/connectors/mcp/{test_mcp_server_config.py => test_mcp.py} (73%) diff --git a/python/semantic_kernel/connectors/mcp.py b/python/semantic_kernel/connectors/mcp.py index f50cc9ab473c..3b888527d682 100644 --- a/python/semantic_kernel/connectors/mcp.py +++ b/python/semantic_kernel/connectors/mcp.py @@ -168,7 +168,7 @@ def get_parameters_from_tool(tool: Tool) -> list[KernelParameterMetadata]: @experimental async def create_plugin_from_mcp_server( plugin_name: str, - description: str, + description: str | None = None, server_config: MCPServerConfig | None = None, **kwargs: Any, ) -> KernelPlugin: diff --git a/python/tests/assets/test_plugins/TestMCPPlugin/mcp_server.py b/python/tests/assets/test_plugins/TestMCPPlugin/mcp_server.py index 7afe7fa2936c..2e8397b95bdc 100644 --- a/python/tests/assets/test_plugins/TestMCPPlugin/mcp_server.py +++ b/python/tests/assets/test_plugins/TestMCPPlugin/mcp_server.py @@ -1,4 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. + from mcp.server.fastmcp import FastMCP # Create an MCP server @@ -6,16 +7,16 @@ @mcp.tool() -def get_secret(name: str) -> int: - """Mocks Get Secret Name""" +def get_name(name: str) -> str: + """Mocks Get Name""" secret_value = "Test" - return f"Secret Value : {secret_value}" + return f"{name}: {secret_value}" @mcp.tool() -def set_secret(name: str, value: str) -> int: - """Mocks Set Secret Name""" - return f"Secret Value for {name} Set" +def set_name(name: str, value: str) -> str: + """Mocks Set Name""" + return f"Value for {name} Set" if __name__ == "__main__": diff --git a/python/tests/unit/connectors/mcp/test_mcp_server_config.py b/python/tests/unit/connectors/mcp/test_mcp.py similarity index 73% rename from python/tests/unit/connectors/mcp/test_mcp_server_config.py rename to python/tests/unit/connectors/mcp/test_mcp.py index 9681ed21d8a1..92f8d7c3805c 100644 --- a/python/tests/unit/connectors/mcp/test_mcp_server_config.py +++ b/python/tests/unit/connectors/mcp/test_mcp.py @@ -1,4 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import os +from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import pytest @@ -7,11 +9,16 @@ from semantic_kernel.connectors.mcp import ( MCPSseServerConfig, MCPStdioServerConfig, + create_plugin_from_mcp_server, ) from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError +from semantic_kernel.functions import KernelArguments +if TYPE_CHECKING: + from semantic_kernel import Kernel -async def test_mcp_client_session_settings_initialize(): + +async def test_mcp_server_config_session_initialize(): # Test if Client can insert it's own Session mock_session = MagicMock(spec=ClientSession) config = MCPSseServerConfig(session=mock_session, url="http://localhost:8080/sse") @@ -19,7 +26,7 @@ async def test_mcp_client_session_settings_initialize(): assert session is mock_session -async def test_mcp_sse_server_settings_initialize_session(): +async def test_mcp_sse_server_config_get_session(): # Patch both the `ClientSession` and `sse_client` independently with ( patch("semantic_kernel.connectors.mcp.ClientSession") as mock_client_session, @@ -43,7 +50,7 @@ async def test_mcp_sse_server_settings_initialize_session(): assert session == mock_client_session -async def test_mcp_stdio_server_settings_initialize_session(): +async def test_mcp_stdio_server_config_get_session(): # Patch both the `ClientSession` and `sse_client` independently with ( patch("semantic_kernel.connectors.mcp.ClientSession") as mock_client_session, @@ -70,7 +77,7 @@ async def test_mcp_stdio_server_settings_initialize_session(): assert session == mock_client_session -async def test_mcp_stdio_server_settings_failed_initialize_session(): +async def test_mcp_stdio_server_config_failed_get_session(): # Patch both the `ClientSession` and `stdio_client` independently with ( patch("semantic_kernel.connectors.mcp.stdio_client") as mock_stdio_client, @@ -95,3 +102,28 @@ async def test_mcp_stdio_server_settings_failed_initialize_session(): with pytest.raises(KernelPluginInvalidConfigurationError): async with settings.get_session(): pass + + +async def test_from_mcp(kernel: "Kernel"): + mcp_server_path = os.path.join(os.path.dirname(__file__), "../../assets/test_plugins", "TestMCPPlugin") + mcp_server_file = "mcp_server.py" + config = MCPStdioServerConfig( + command="uv", + args=["--directory", mcp_server_path, "run", mcp_server_file], + ) + + plugin = await create_plugin_from_mcp_server( + plugin_name="TestMCPPlugin", + description="Test MCP Plugin", + server_config=config, + ) + + assert plugin is not None + assert plugin.name == "TestMCPPlugin" + assert plugin.functions.get("get_name") is not None + assert plugin.functions.get("set_name") is not None + + kernel.add_plugin(plugin) + + result = await plugin.functions["get_name"].invoke(kernel, arguments=KernelArguments(name="test")) + assert result.value == "test: Test" diff --git a/python/tests/unit/functions/test_kernel_plugins.py b/python/tests/unit/functions/test_kernel_plugins.py index 8a1eadab391a..cdcae62915b2 100644 --- a/python/tests/unit/functions/test_kernel_plugins.py +++ b/python/tests/unit/functions/test_kernel_plugins.py @@ -8,7 +8,6 @@ from pytest import raises from semantic_kernel.connectors.ai import PromptExecutionSettings -from semantic_kernel.connectors.mcp import MCPStdioServerConfig from semantic_kernel.connectors.openapi_plugin.openapi_parser import OpenApiParser from semantic_kernel.exceptions.function_exceptions import PluginInitializationError from semantic_kernel.functions import kernel_function @@ -507,26 +506,6 @@ def test_from_openapi(): assert plugin.functions.get("SetSecret") is not None -@pytest.mark.asyncio -async def test_from_mcp(): - mcp_server_path = os.path.join(os.path.dirname(__file__), "../../assets/test_plugins", "TestMCPPlugin") - mcp_server_file = "mcp_server.py" - settings = MCPStdioServerConfig( - command="uv", - args=["--directory", mcp_server_path, "run", mcp_server_file], - ) - - plugin = await KernelPlugin.from_mcp_server( - plugin_name="TestMCPPlugin", - execution_settings=settings, - ) - - assert plugin is not None - assert plugin.name == "TestMCPPlugin" - assert plugin.functions.get("get_secret") is not None - assert plugin.functions.get("set_secret") is not None - - def test_custom_spec_from_openapi(): openapi_spec_file = os.path.join( os.path.dirname(__file__), "../../assets/test_plugins", "TestOpenAPIPlugin", "akv-openapi.yaml" From 9d758e91339cbe776f9e94dc3156393fd3bb914e Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 2 Apr 2025 14:43:04 +0200 Subject: [PATCH 07/19] removed old test --- python/tests/unit/kernel/test_kernel.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/python/tests/unit/kernel/test_kernel.py b/python/tests/unit/kernel/test_kernel.py index 1ebb8c863b6e..f99df0696595 100644 --- a/python/tests/unit/kernel/test_kernel.py +++ b/python/tests/unit/kernel/test_kernel.py @@ -12,7 +12,6 @@ from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings -from semantic_kernel.connectors.mcp import MCPStdioServerConfig from semantic_kernel.const import METADATA_EXCEPTION_KEY from semantic_kernel.contents import ChatMessageContent from semantic_kernel.contents.chat_history import ChatHistory @@ -617,27 +616,6 @@ def test_import_plugin_from_openapi(kernel: Kernel): assert plugin.functions.get("SetSecret") is not None -@pytest.mark.asyncio -async def test_import_plugin_from_mcp(kernel: Kernel): - mcp_server_path = os.path.join(os.path.dirname(__file__), "../../assets/test_plugins", "TestMCPPlugin") - mcp_server_file = "mcp_server.py" - settings = MCPStdioServerConfig( - command="uv", - args=["--directory", mcp_server_path, "run", mcp_server_file], - ) - - await kernel.add_plugin_from_mcp( - plugin_name="TestMCPPlugin", - execution_settings=settings, - ) - - plugin = kernel.get_plugin(plugin_name="TestMCPPlugin") - assert plugin is not None - assert plugin.name == "TestMCPPlugin" - assert plugin.functions.get("get_secret") is not None - assert plugin.functions.get("set_secret") is not None - - def test_get_plugin(kernel: Kernel): kernel.add_plugin(KernelPlugin(name="TestPlugin")) plugin = kernel.get_plugin("TestPlugin") From 9622a53c27b811c8d03a45d6c6d324c3056b546f Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 2 Apr 2025 15:37:02 +0200 Subject: [PATCH 08/19] fix tests --- python/semantic_kernel/connectors/mcp.py | 6 ++- python/tests/unit/connectors/mcp/test_mcp.py | 54 ++++++++++++++++++-- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/python/semantic_kernel/connectors/mcp.py b/python/semantic_kernel/connectors/mcp.py index 3b888527d682..cef1d7db9bb5 100644 --- a/python/semantic_kernel/connectors/mcp.py +++ b/python/semantic_kernel/connectors/mcp.py @@ -55,7 +55,8 @@ async def call_tool(self, tool_name: str, **kwargs: Any) -> Any: """Call a tool with the given arguments.""" try: async with self.get_session() as session: - return await session.call_tool(tool_name, arguments=kwargs) + result = await session.call_tool(tool_name, arguments=kwargs) + return result.model_dump_json(include=("content",)) except McpError: raise except Exception as ex: @@ -126,8 +127,9 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: """Get an MCP SSE client.""" args: dict[str, Any] = { "url": self.url, - "headers": self.headers, } + if self.headers: + args["headers"] = self.headers if self.timeout is not None: args["timeout"] = self.timeout if self.sse_read_timeout is not None: diff --git a/python/tests/unit/connectors/mcp/test_mcp.py b/python/tests/unit/connectors/mcp/test_mcp.py index 92f8d7c3805c..40c47d11fd48 100644 --- a/python/tests/unit/connectors/mcp/test_mcp.py +++ b/python/tests/unit/connectors/mcp/test_mcp.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch import pytest -from mcp import ClientSession +from mcp import ClientSession, StdioServerParameters from semantic_kernel.connectors.mcp import ( MCPSseServerConfig, @@ -105,7 +105,7 @@ async def test_mcp_stdio_server_config_failed_get_session(): async def test_from_mcp(kernel: "Kernel"): - mcp_server_path = os.path.join(os.path.dirname(__file__), "../../assets/test_plugins", "TestMCPPlugin") + mcp_server_path = os.path.join(os.path.dirname(__file__), "../../../assets/test_plugins", "TestMCPPlugin") mcp_server_file = "mcp_server.py" config = MCPStdioServerConfig( command="uv", @@ -121,9 +121,57 @@ async def test_from_mcp(kernel: "Kernel"): assert plugin is not None assert plugin.name == "TestMCPPlugin" assert plugin.functions.get("get_name") is not None + assert plugin.functions["get_name"].parameters[0].name == "name" + assert plugin.functions["get_name"].parameters[0].type_ == "string" + assert plugin.functions["get_name"].parameters[0].is_required assert plugin.functions.get("set_name") is not None kernel.add_plugin(plugin) result = await plugin.functions["get_name"].invoke(kernel, arguments=KernelArguments(name="test")) - assert result.value == "test: Test" + assert "test: Test" in result.value + + +@patch("semantic_kernel.connectors.mcp.stdio_client") +@patch("semantic_kernel.connectors.mcp.ClientSession") +async def test_with_kwargs_stdio(mock_session, mock_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__.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 + await create_plugin_from_mcp_server( + plugin_name="TestMCPPlugin", + description="Test MCP Plugin", + command="uv", + args=["--directory", "path", "run", "file.py"], + ) + mock_client.assert_called_once_with( + server=StdioServerParameters(command="uv", args=["--directory", "path", "run", "file.py"]) + ) + + +@patch("semantic_kernel.connectors.mcp.sse_client") +@patch("semantic_kernel.connectors.mcp.ClientSession") +async def test_with_kwargs_sse(mock_session, mock_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__.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 + await create_plugin_from_mcp_server( + plugin_name="TestMCPPlugin", + description="Test MCP Plugin", + url="http://localhost:8080/sse", + ) + mock_client.assert_called_once_with(url="http://localhost:8080/sse") From 09ff0e6181b3a871db02ed73c82b59496c0b9b78 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 2 Apr 2025 17:24:17 +0200 Subject: [PATCH 09/19] improved parsing of schema and tests --- python/semantic_kernel/connectors/mcp.py | 10 +-- .../integration/mcp/test_mcp_integration.py | 39 ++++++++++ python/tests/unit/connectors/mcp/test_mcp.py | 78 +++++++++++-------- 3 files changed, 87 insertions(+), 40 deletions(-) create mode 100644 python/tests/integration/mcp/test_mcp_integration.py diff --git a/python/semantic_kernel/connectors/mcp.py b/python/semantic_kernel/connectors/mcp.py index cef1d7db9bb5..1bbc0ba1f2a0 100644 --- a/python/semantic_kernel/connectors/mcp.py +++ b/python/semantic_kernel/connectors/mcp.py @@ -6,8 +6,10 @@ from functools import partial from typing import Any -from mcp import ClientSession, McpError, StdioServerParameters, stdio_client +from mcp import McpError +from mcp.client.session import ClientSession from mcp.client.sse import sse_client +from mcp.client.stdio import StdioServerParameters, stdio_client from mcp.types import Tool from pydantic import BaseModel, ConfigDict, Field @@ -157,11 +159,7 @@ def get_parameters_from_tool(tool: Tool) -> list[KernelParameterMetadata]: is_required=prop_name in required, type=prop_details.get("type"), default_value=prop_details.get("default", None), - schema_data=prop_details["items"] - if "items" in prop_details and prop_details["items"] is not None and isinstance(prop_details["items"], dict) - else {"type": f"{prop_details['type']}"} - if "type" in prop_details - else None, + schema_data=prop_details, ) for prop_name, prop_details in properties.items() ] diff --git a/python/tests/integration/mcp/test_mcp_integration.py b/python/tests/integration/mcp/test_mcp_integration.py new file mode 100644 index 000000000000..4104b1edaf68 --- /dev/null +++ b/python/tests/integration/mcp/test_mcp_integration.py @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft. All rights reserved. + + +import os +from typing import TYPE_CHECKING + +from semantic_kernel.connectors.mcp import MCPStdioServerConfig, create_plugin_from_mcp_server +from semantic_kernel.functions.kernel_arguments import KernelArguments + +if TYPE_CHECKING: + from semantic_kernel import Kernel + + +async def test_from_mcp(kernel: "Kernel"): + mcp_server_path = os.path.join(os.path.dirname(__file__), "../../assets/test_plugins", "TestMCPPlugin") + mcp_server_file = "mcp_server.py" + config = MCPStdioServerConfig( + command="uv", + args=["--directory", mcp_server_path, "run", mcp_server_file], + ) + + plugin = await create_plugin_from_mcp_server( + plugin_name="TestMCPPlugin", + description="Test MCP Plugin", + server_config=config, + ) + + assert plugin is not None + assert plugin.name == "TestMCPPlugin" + assert plugin.functions.get("get_name") is not None + assert plugin.functions["get_name"].parameters[0].name == "name" + assert plugin.functions["get_name"].parameters[0].type_ == "string" + assert plugin.functions["get_name"].parameters[0].is_required + assert plugin.functions.get("set_name") is not None + + kernel.add_plugin(plugin) + + result = await plugin.functions["get_name"].invoke(kernel, arguments=KernelArguments(name="test")) + assert "test: Test" in result.value diff --git a/python/tests/unit/connectors/mcp/test_mcp.py b/python/tests/unit/connectors/mcp/test_mcp.py index 40c47d11fd48..e219c1515f5d 100644 --- a/python/tests/unit/connectors/mcp/test_mcp.py +++ b/python/tests/unit/connectors/mcp/test_mcp.py @@ -1,10 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. -import os from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import pytest -from mcp import ClientSession, StdioServerParameters +from mcp import ClientSession, ListToolsResult, StdioServerParameters, Tool from semantic_kernel.connectors.mcp import ( MCPSseServerConfig, @@ -12,10 +11,9 @@ create_plugin_from_mcp_server, ) from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError -from semantic_kernel.functions import KernelArguments if TYPE_CHECKING: - from semantic_kernel import Kernel + pass async def test_mcp_server_config_session_initialize(): @@ -104,34 +102,6 @@ async def test_mcp_stdio_server_config_failed_get_session(): pass -async def test_from_mcp(kernel: "Kernel"): - mcp_server_path = os.path.join(os.path.dirname(__file__), "../../../assets/test_plugins", "TestMCPPlugin") - mcp_server_file = "mcp_server.py" - config = MCPStdioServerConfig( - command="uv", - args=["--directory", mcp_server_path, "run", mcp_server_file], - ) - - plugin = await create_plugin_from_mcp_server( - plugin_name="TestMCPPlugin", - description="Test MCP Plugin", - server_config=config, - ) - - assert plugin is not None - assert plugin.name == "TestMCPPlugin" - assert plugin.functions.get("get_name") is not None - assert plugin.functions["get_name"].parameters[0].name == "name" - assert plugin.functions["get_name"].parameters[0].type_ == "string" - assert plugin.functions["get_name"].parameters[0].is_required - assert plugin.functions.get("set_name") is not None - - kernel.add_plugin(plugin) - - result = await plugin.functions["get_name"].invoke(kernel, arguments=KernelArguments(name="test")) - assert "test: Test" in result.value - - @patch("semantic_kernel.connectors.mcp.stdio_client") @patch("semantic_kernel.connectors.mcp.ClientSession") async def test_with_kwargs_stdio(mock_session, mock_client): @@ -145,7 +115,21 @@ async def test_with_kwargs_stdio(mock_session, mock_client): # Make the mock_stdio_client return an AsyncMock for the context manager mock_client.return_value = mock_generator - await create_plugin_from_mcp_server( + mock_session.return_value.__aenter__.return_value.list_tools.return_value = ListToolsResult( + tools=[ + Tool( + name="get_name", + description="Get Name", + inputSchema={ + "properties": { + "name": {"type": "string"}, + }, + "required": ["name"], + }, + ) + ] + ) + plugin = await create_plugin_from_mcp_server( plugin_name="TestMCPPlugin", description="Test MCP Plugin", command="uv", @@ -154,6 +138,12 @@ async def test_with_kwargs_stdio(mock_session, mock_client): mock_client.assert_called_once_with( server=StdioServerParameters(command="uv", args=["--directory", "path", "run", "file.py"]) ) + assert plugin is not None + assert plugin.name == "TestMCPPlugin" + assert plugin.description == "Test MCP Plugin" + assert plugin.functions.get("get_name") is not None + assert plugin.functions["get_name"].parameters[0].name == "name" + assert plugin.functions["get_name"].parameters[0].is_required @patch("semantic_kernel.connectors.mcp.sse_client") @@ -169,9 +159,29 @@ async def test_with_kwargs_sse(mock_session, mock_client): # Make the mock_stdio_client return an AsyncMock for the context manager mock_client.return_value = mock_generator - await create_plugin_from_mcp_server( + mock_session.return_value.__aenter__.return_value.list_tools.return_value = ListToolsResult( + tools=[ + Tool( + name="get_name", + description="Get Name", + inputSchema={ + "properties": { + "name": {"type": "string"}, + }, + "required": ["name"], + }, + ) + ] + ) + plugin = await create_plugin_from_mcp_server( plugin_name="TestMCPPlugin", description="Test MCP Plugin", url="http://localhost:8080/sse", ) mock_client.assert_called_once_with(url="http://localhost:8080/sse") + assert plugin is not None + assert plugin.name == "TestMCPPlugin" + assert plugin.description == "Test MCP Plugin" + assert plugin.functions.get("get_name") is not None + assert plugin.functions["get_name"].parameters[0].name == "name" + assert plugin.functions["get_name"].parameters[0].is_required From 47a43dab88f59aa2218dbae4e749b260d1d1b048 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 2 Apr 2025 20:17:18 +0200 Subject: [PATCH 10/19] added tests for funcs without params --- python/semantic_kernel/connectors/mcp.py | 10 +-- .../test_plugins/TestMCPPlugin/mcp_server.py | 6 ++ python/tests/unit/connectors/mcp/test_mcp.py | 75 +++++++++---------- 3 files changed, 45 insertions(+), 46 deletions(-) diff --git a/python/semantic_kernel/connectors/mcp.py b/python/semantic_kernel/connectors/mcp.py index 1bbc0ba1f2a0..f65e7468cc8f 100644 --- a/python/semantic_kernel/connectors/mcp.py +++ b/python/semantic_kernel/connectors/mcp.py @@ -13,7 +13,7 @@ from mcp.types import Tool from pydantic import BaseModel, ConfigDict, Field -from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError, ServiceInvalidTypeError +from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException from semantic_kernel.functions import KernelFunctionFromMethod from semantic_kernel.functions.kernel_function_decorator import kernel_function @@ -147,12 +147,8 @@ def get_parameters_from_tool(tool: Tool) -> list[KernelParameterMetadata]: properties = tool.inputSchema.get("properties", None) required = tool.inputSchema.get("required", None) # Check if 'properties' is missing or not a dictionary - if properties is None or not isinstance(properties, dict): - raise ServiceInvalidTypeError("""Could not parse tool properties, - please ensure your server returns properties as a dictionary and required as an array.""") - if required is None or not isinstance(required, list): - raise ServiceInvalidTypeError("""Could not parse tool required fields, - please ensure your server returns required as an array.""") + if not properties: + return [] return [ KernelParameterMetadata( name=prop_name, diff --git a/python/tests/assets/test_plugins/TestMCPPlugin/mcp_server.py b/python/tests/assets/test_plugins/TestMCPPlugin/mcp_server.py index 2e8397b95bdc..d18ad3963a01 100644 --- a/python/tests/assets/test_plugins/TestMCPPlugin/mcp_server.py +++ b/python/tests/assets/test_plugins/TestMCPPlugin/mcp_server.py @@ -19,6 +19,12 @@ def set_name(name: str, value: str) -> str: return f"Value for {name} Set" +@mcp.tool() +def get_names() -> str: + """Mocks Get Names""" + return "Names: name1, name2, name3" + + if __name__ == "__main__": # Initialize and run the server mcp.run(transport="stdio") diff --git a/python/tests/unit/connectors/mcp/test_mcp.py b/python/tests/unit/connectors/mcp/test_mcp.py index e219c1515f5d..54ae5d3a7d24 100644 --- a/python/tests/unit/connectors/mcp/test_mcp.py +++ b/python/tests/unit/connectors/mcp/test_mcp.py @@ -1,5 +1,4 @@ # Copyright (c) Microsoft. All rights reserved. -from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import pytest @@ -12,8 +11,28 @@ ) from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError -if TYPE_CHECKING: - pass + +@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={}, + ), + ] + ) async def test_mcp_server_config_session_initialize(): @@ -104,7 +123,7 @@ async def test_mcp_stdio_server_config_failed_get_session(): @patch("semantic_kernel.connectors.mcp.stdio_client") @patch("semantic_kernel.connectors.mcp.ClientSession") -async def test_with_kwargs_stdio(mock_session, mock_client): +async def test_with_kwargs_stdio(mock_session, mock_client, list_tool_calls): mock_read = MagicMock() mock_write = MagicMock() @@ -115,20 +134,7 @@ async def test_with_kwargs_stdio(mock_session, mock_client): # 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 = ListToolsResult( - tools=[ - Tool( - name="get_name", - description="Get Name", - inputSchema={ - "properties": { - "name": {"type": "string"}, - }, - "required": ["name"], - }, - ) - ] - ) + mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls plugin = await create_plugin_from_mcp_server( plugin_name="TestMCPPlugin", description="Test MCP Plugin", @@ -141,14 +147,16 @@ async def test_with_kwargs_stdio(mock_session, mock_client): assert plugin is not None assert plugin.name == "TestMCPPlugin" assert plugin.description == "Test MCP Plugin" - assert plugin.functions.get("get_name") is not None - assert plugin.functions["get_name"].parameters[0].name == "name" - assert plugin.functions["get_name"].parameters[0].is_required + assert plugin.functions.get("func1") is not None + assert plugin.functions["func1"].parameters[0].name == "name" + assert plugin.functions["func1"].parameters[0].is_required + assert plugin.functions.get("func2") is not None + assert len(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): +async def test_with_kwargs_sse(mock_session, mock_client, list_tool_calls): mock_read = MagicMock() mock_write = MagicMock() @@ -159,20 +167,7 @@ async def test_with_kwargs_sse(mock_session, mock_client): # 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 = ListToolsResult( - tools=[ - Tool( - name="get_name", - description="Get Name", - inputSchema={ - "properties": { - "name": {"type": "string"}, - }, - "required": ["name"], - }, - ) - ] - ) + mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls plugin = await create_plugin_from_mcp_server( plugin_name="TestMCPPlugin", description="Test MCP Plugin", @@ -182,6 +177,8 @@ async def test_with_kwargs_sse(mock_session, mock_client): assert plugin is not None assert plugin.name == "TestMCPPlugin" assert plugin.description == "Test MCP Plugin" - assert plugin.functions.get("get_name") is not None - assert plugin.functions["get_name"].parameters[0].name == "name" - assert plugin.functions["get_name"].parameters[0].is_required + assert plugin.functions.get("func1") is not None + assert plugin.functions["func1"].parameters[0].name == "name" + assert plugin.functions["func1"].parameters[0].is_required + assert plugin.functions.get("func2") is not None + assert len(plugin.functions["func2"].parameters) == 0 From ce121a5d5a6183a3509588463b62796f37eac3b8 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 2 Apr 2025 21:35:46 +0200 Subject: [PATCH 11/19] added prompts --- python/samples/concepts/mcp/mcp_as_plugin.py | 24 ++- python/semantic_kernel/connectors/mcp.py | 166 ++++++++++++++++-- .../test_plugins/TestMCPPlugin/mcp_server.py | 26 ++- .../integration/mcp/test_mcp_integration.py | 13 +- 4 files changed, 182 insertions(+), 47 deletions(-) diff --git a/python/samples/concepts/mcp/mcp_as_plugin.py b/python/samples/concepts/mcp/mcp_as_plugin.py index 91c84ae27352..6c5578b54e10 100644 --- a/python/samples/concepts/mcp/mcp_as_plugin.py +++ b/python/samples/concepts/mcp/mcp_as_plugin.py @@ -5,7 +5,7 @@ from samples.concepts.setup.chat_completion_services import Services, get_chat_completion_service_and_request_settings from semantic_kernel import Kernel from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior -from semantic_kernel.connectors.mcp import create_plugin_from_mcp_server +from semantic_kernel.connectors.mcp import mcp_server_as_plugin from semantic_kernel.contents import ChatHistory """ @@ -86,17 +86,23 @@ async def chat() -> bool: async def main() -> None: # Make sure to have NPX installed and available in your PATH. # Find the NPX executable in the system PATH. - github_plugin = await create_plugin_from_mcp_server( - plugin_name="GitHub", + # github_plugin, _ = await create_plugin_from_mcp_server( + # plugin_name="GitHub", + # description="Github Plugin", + # command="npx", + # args=["-y", "@modelcontextprotocol/server-github"], + # ) + async with mcp_server_as_plugin( + plugin_name="Github", description="Github Plugin", command="npx", args=["-y", "@modelcontextprotocol/server-github"], - ) - kernel.add_plugin(github_plugin) - print("Welcome to the chat bot!\n Type 'exit' to exit.\n") - chatting = True - while chatting: - chatting = await chat() + ) as github_plugin: + kernel.add_plugin(github_plugin) + print("Welcome to the chat bot!\n Type 'exit' to exit.\n") + chatting = True + while chatting: + chatting = await chat() if __name__ == "__main__": diff --git a/python/semantic_kernel/connectors/mcp.py b/python/semantic_kernel/connectors/mcp.py index f65e7468cc8f..2358e2905d5c 100644 --- a/python/semantic_kernel/connectors/mcp.py +++ b/python/semantic_kernel/connectors/mcp.py @@ -2,6 +2,7 @@ import logging from abc import abstractmethod +from collections.abc import AsyncGenerator from contextlib import _AsyncGeneratorContextManager, asynccontextmanager from functools import partial from typing import Any @@ -10,9 +11,20 @@ from mcp.client.session import ClientSession from mcp.client.sse import sse_client from mcp.client.stdio import StdioServerParameters, stdio_client -from mcp.types import Tool +from mcp.types import CallToolResult, EmbeddedResource, Prompt, PromptMessage, TextResourceContents, Tool +from mcp.types import ( + ImageContent as MCPImageContent, +) +from mcp.types import ( + TextContent as MCPTextContent, +) from pydantic import BaseModel, ConfigDict, Field +from semantic_kernel.contents.binary_content import BinaryContent +from semantic_kernel.contents.chat_message_content import ChatMessageContent +from semantic_kernel.contents.image_content import ImageContent +from semantic_kernel.contents.text_content import TextContent +from semantic_kernel.contents.utils.author_role import AuthorRole from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException from semantic_kernel.functions import KernelFunctionFromMethod @@ -24,6 +36,46 @@ logger = logging.getLogger(__name__) +def mcp_prompt_message_to_semantic_kernel_type( + mcp_type: PromptMessage, +) -> ChatMessageContent: + """Convert a MCP container type to a Semantic Kernel type.""" + return ChatMessageContent( + role=AuthorRole(mcp_type.role), + items=[mcp_type_to_semantic_kernel_type(mcp_type.content)], + inner_content=mcp_type, + ) + + +def mcp_call_tool_result_to_semantic_kernel_type( + mcp_type: CallToolResult, +) -> list[TextContent | ImageContent | BinaryContent]: + """Convert a MCP container type to a Semantic Kernel type.""" + return [mcp_type_to_semantic_kernel_type(item) for item in mcp_type.content] + + +def mcp_type_to_semantic_kernel_type( + mcp_type: MCPImageContent | MCPTextContent | EmbeddedResource, +) -> TextContent | ImageContent | BinaryContent: + """Convert a MCP type to a Semantic Kernel type.""" + if isinstance(mcp_type, MCPTextContent): + return TextContent(text=mcp_type.text, inner_content=mcp_type) + if isinstance(mcp_type, MCPImageContent): + return ImageContent(data=mcp_type.data, mime_type=mcp_type.mimeType, inner_content=mcp_type) + + if isinstance(mcp_type.resource, TextResourceContents): + return TextContent( + text=mcp_type.resource.text, + inner_content=mcp_type, + metadata=mcp_type.annotations.model_dump() if mcp_type.annotations else {}, + ) + return BinaryContent( + data=mcp_type.resource.blob, + inner_content=mcp_type, + metadata=mcp_type.annotations.model_dump() if mcp_type.annotations else {}, + ) + + class MCPServerConfig(BaseModel): """MCP server configuration.""" @@ -53,17 +105,29 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: """Get an MCP client.""" pass - async def call_tool(self, tool_name: str, **kwargs: Any) -> Any: + async def call_tool(self, tool_name: str, **kwargs: Any) -> list[TextContent | ImageContent | BinaryContent]: """Call a tool with the given arguments.""" try: async with self.get_session() as session: - result = await session.call_tool(tool_name, arguments=kwargs) - return result.model_dump_json(include=("content",)) + return mcp_call_tool_result_to_semantic_kernel_type( + await session.call_tool(tool_name, arguments=kwargs) + ) except McpError: raise except Exception as ex: raise FunctionExecutionException(f"Failed to call tool '{tool_name}'.") from ex + async def get_prompt(self, prompt_name: str, **kwargs: Any) -> list[ChatMessageContent]: + """Call a prompt with the given arguments.""" + try: + async with self.get_session() as session: + prompt_result = await session.get_prompt(prompt_name, arguments=kwargs) + return [mcp_prompt_message_to_semantic_kernel_type(message) for message in prompt_result.messages] + except McpError: + raise + except Exception as ex: + raise FunctionExecutionException(f"Failed to call prompt '{prompt_name}'.") from ex + class MCPStdioServerConfig(MCPServerConfig): """MCP stdio server configuration. @@ -142,7 +206,24 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: @experimental -def get_parameters_from_tool(tool: Tool) -> list[KernelParameterMetadata]: +def get_parameter_from_mcp_prompt(prompt: Prompt) -> list[KernelParameterMetadata]: + """Creates a MCPFunction instance from a prompt.""" + # Check if 'properties' is missing or not a dictionary + if not prompt.arguments: + return [] + return [ + KernelParameterMetadata( + name=prompt_argument.name, + description=prompt_argument.description, + is_required=True, + type_object=str, + ) + for prompt_argument in prompt.arguments + ] + + +@experimental +def get_parameters_from_mcp_tool(tool: Tool) -> list[KernelParameterMetadata]: """Creates an MCPFunction instance from a tool.""" properties = tool.inputSchema.get("properties", None) required = tool.inputSchema.get("required", None) @@ -167,7 +248,7 @@ async def create_plugin_from_mcp_server( description: str | None = None, server_config: MCPServerConfig | None = None, **kwargs: Any, -) -> KernelPlugin: +) -> tuple[KernelPlugin, MCPServerConfig]: """Creates a KernelPlugin from a MCP server config. Args: @@ -180,6 +261,7 @@ async def create_plugin_from_mcp_server( Returns: KernelPlugin: The created plugin, this should then be passed to the kernel or a agent. + MCPServerConfig: The server config used to create the plugin. """ if server_config is None: @@ -202,16 +284,66 @@ async def create_plugin_from_mcp_server( "Failed to create MCP server configuration, please provide a valid server_config or kwargs." ) async with server_config.get_session() as session: - return KernelPlugin( - name=plugin_name, + try: + tool_list = await session.list_tools() + except Exception: + tool_list = None + tools = [ + KernelFunctionFromMethod( + method=kernel_function(name=tool.name, description=tool.description)( + partial(server_config.call_tool, tool.name) + ), + parameters=get_parameters_from_mcp_tool(tool), + ) + for tool in (tool_list.tools if tool_list else []) + ] + try: + prompt_list = await session.list_prompts() + except Exception: + prompt_list = None + prompts = [ + KernelFunctionFromMethod( + method=kernel_function(name=prompt.name, description=prompt.description)( + partial(server_config.get_prompt, prompt.name) + ), + parameters=get_parameter_from_mcp_prompt(prompt), + ) + for prompt in (prompt_list.prompts if prompt_list else []) + ] + return (KernelPlugin(name=plugin_name, description=description, functions=tools + prompts), server_config) + + +@asynccontextmanager +async def mcp_server_as_plugin( + plugin_name: str, + description: str | None = None, + server_config: MCPServerConfig | None = None, + **kwargs: Any, +) -> AsyncGenerator[KernelPlugin, None]: + """Creates a KernelPlugin from a MCP server config. + + Args: + plugin_name: The name of the plugin. + description: The description of the plugin. + server_config: The MCP client to use for communication, + should be a MCPStdioServerConfig or MCPSseServerConfig. + If not supplied, it will be created from the kwargs. + kwargs: Any extra arguments to pass to the plugin creation. + + Yields: + KernelPlugin: The created plugin, this should then be passed to the kernel or a agent. + + """ + server = None + try: + plugin, server = await create_plugin_from_mcp_server( + plugin_name=plugin_name, description=description, - functions=[ - KernelFunctionFromMethod( - method=kernel_function(name=tool.name, description=tool.description)( - partial(server_config.call_tool, tool.name) - ), - parameters=get_parameters_from_tool(tool), - ) - for tool in (await session.list_tools()).tools - ], + server_config=server_config, + **kwargs, ) + yield plugin + finally: + # Close the session if it was created in this context + if server and server.session: + await server.session.__aexit__() diff --git a/python/tests/assets/test_plugins/TestMCPPlugin/mcp_server.py b/python/tests/assets/test_plugins/TestMCPPlugin/mcp_server.py index d18ad3963a01..63c741ded463 100644 --- a/python/tests/assets/test_plugins/TestMCPPlugin/mcp_server.py +++ b/python/tests/assets/test_plugins/TestMCPPlugin/mcp_server.py @@ -2,27 +2,25 @@ from mcp.server.fastmcp import FastMCP -# Create an MCP server -mcp = FastMCP("DemoServerForTesting", "This is a demo server for testing purposes.") +mcp = FastMCP("Echo") -@mcp.tool() -def get_name(name: str) -> str: - """Mocks Get Name""" - secret_value = "Test" - return f"{name}: {secret_value}" +@mcp.resource("echo://{message}") +def echo_resource(message: str) -> str: + """Echo a message as a resource""" + return f"Resource echo: {message}" @mcp.tool() -def set_name(name: str, value: str) -> str: - """Mocks Set Name""" - return f"Value for {name} Set" +def echo_tool(message: str) -> str: + """Echo a message as a tool""" + return f"Tool echo: {message}" -@mcp.tool() -def get_names() -> str: - """Mocks Get Names""" - return "Names: name1, name2, name3" +@mcp.prompt() +def echo_prompt(message: str) -> str: + """Create an echo prompt""" + return f"Please process this message: {message}" if __name__ == "__main__": diff --git a/python/tests/integration/mcp/test_mcp_integration.py b/python/tests/integration/mcp/test_mcp_integration.py index 4104b1edaf68..a2ab740b8485 100644 --- a/python/tests/integration/mcp/test_mcp_integration.py +++ b/python/tests/integration/mcp/test_mcp_integration.py @@ -27,13 +27,12 @@ async def test_from_mcp(kernel: "Kernel"): assert plugin is not None assert plugin.name == "TestMCPPlugin" - assert plugin.functions.get("get_name") is not None - assert plugin.functions["get_name"].parameters[0].name == "name" - assert plugin.functions["get_name"].parameters[0].type_ == "string" - assert plugin.functions["get_name"].parameters[0].is_required - assert plugin.functions.get("set_name") is not None + assert len(plugin.functions) == 2 kernel.add_plugin(plugin) - result = await plugin.functions["get_name"].invoke(kernel, arguments=KernelArguments(name="test")) - assert "test: Test" in result.value + result = await plugin.functions["echo_tool"].invoke(kernel, arguments=KernelArguments(message="test")) + assert "Tool echo: test" in result.value[0].text + + result = await plugin.functions["echo_prompt"].invoke(kernel, arguments=KernelArguments(message="test")) + assert "test" in result.value[0].content From 80ba6c081aca92217ddf256d95a53bdaa5b541b5 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 3 Apr 2025 11:24:32 +0200 Subject: [PATCH 12/19] rebuilt as plugin --- python/samples/concepts/mcp/mcp_as_plugin.py | 37 +- python/semantic_kernel/connectors/mcp.py | 463 +++++++++--------- .../functions/kernel_plugin.py | 3 + .../integration/mcp/test_mcp_integration.py | 38 +- python/tests/unit/connectors/mcp/test_mcp.py | 152 +++--- 5 files changed, 335 insertions(+), 358 deletions(-) diff --git a/python/samples/concepts/mcp/mcp_as_plugin.py b/python/samples/concepts/mcp/mcp_as_plugin.py index 6c5578b54e10..082f9f52e692 100644 --- a/python/samples/concepts/mcp/mcp_as_plugin.py +++ b/python/samples/concepts/mcp/mcp_as_plugin.py @@ -5,7 +5,7 @@ from samples.concepts.setup.chat_completion_services import Services, get_chat_completion_service_and_request_settings from semantic_kernel import Kernel from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior -from semantic_kernel.connectors.mcp import mcp_server_as_plugin +from semantic_kernel.connectors.mcp import MCPStdioPlugin from semantic_kernel.contents import ChatHistory """ @@ -14,6 +14,16 @@ it creates a Plugin from a MCP server config and adds it to the kernel. The chatbot is designed to interact with the user, call MCP tools as needed, and return responses. + +To run this sample, make sure to run: +`pip install semantic-kernel[mcp]` + +or install the mcp package manually. + +In addition, different MCP Stdio servers need different commands to run. +For example, the Github plugin requires `npx`, others use `uvx` or `docker`. + +Make sure those are available in your PATH. """ # System message defining the behavior and persona of the chat bot. @@ -69,7 +79,6 @@ async def chat() -> bool: except (KeyboardInterrupt, EOFError): print("\n\nExiting chat...") return False - if user_input.lower().strip() == "exit": print("\n\nExiting chat...") return False @@ -84,21 +93,25 @@ async def chat() -> bool: async def main() -> None: - # Make sure to have NPX installed and available in your PATH. - # Find the NPX executable in the system PATH. - # github_plugin, _ = await create_plugin_from_mcp_server( - # plugin_name="GitHub", - # description="Github Plugin", - # command="npx", - # args=["-y", "@modelcontextprotocol/server-github"], - # ) - async with mcp_server_as_plugin( - plugin_name="Github", + # Create a plugin from the MCP server config and add it to the kernel. + # The MCP server plugin is defined using the MCPStdioPlugin class. + # The command and args are specific to the MCP server you want to run. + # For example, the Github MCP Server uses `npx` to run the server. + # There is also a MCPSsePlugin, which takes a URL. + async with MCPStdioPlugin( + name="Github", description="Github Plugin", command="npx", args=["-y", "@modelcontextprotocol/server-github"], ) as github_plugin: + # instead of using this async context manager, you can also use: + # await github_plugin.connect() + # and then await github_plugin.close() at the end of the program. + + # Add the plugin to the kernel. kernel.add_plugin(github_plugin) + + # Start the chat loop. print("Welcome to the chat bot!\n Type 'exit' to exit.\n") chatting = True while chatting: diff --git a/python/semantic_kernel/connectors/mcp.py b/python/semantic_kernel/connectors/mcp.py index 2358e2905d5c..c558296521a5 100644 --- a/python/semantic_kernel/connectors/mcp.py +++ b/python/semantic_kernel/connectors/mcp.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. import logging +import sys from abc import abstractmethod -from collections.abc import AsyncGenerator -from contextlib import _AsyncGeneratorContextManager, asynccontextmanager +from contextlib import AsyncExitStack, _AsyncGeneratorContextManager from functools import partial from typing import Any @@ -18,7 +18,6 @@ from mcp.types import ( TextContent as MCPTextContent, ) -from pydantic import BaseModel, ConfigDict, Field from semantic_kernel.contents.binary_content import BinaryContent from semantic_kernel.contents.chat_message_content import ChatMessageContent @@ -27,34 +26,39 @@ from semantic_kernel.contents.utils.author_role import AuthorRole from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException -from semantic_kernel.functions import KernelFunctionFromMethod from semantic_kernel.functions.kernel_function_decorator import kernel_function -from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata -from semantic_kernel.functions.kernel_plugin import KernelPlugin from semantic_kernel.utils.feature_stage_decorator import experimental +if sys.version_info >= (3, 11): + from typing import Self # pragma: no cover +else: + from typing_extensions import Self # pragma: no cover + logger = logging.getLogger(__name__) -def mcp_prompt_message_to_semantic_kernel_type( +@experimental +def _mcp_prompt_message_to_semantic_kernel_type( mcp_type: PromptMessage, ) -> ChatMessageContent: """Convert a MCP container type to a Semantic Kernel type.""" return ChatMessageContent( role=AuthorRole(mcp_type.role), - items=[mcp_type_to_semantic_kernel_type(mcp_type.content)], + items=[_mcp_type_to_semantic_kernel_type(mcp_type.content)], inner_content=mcp_type, ) -def mcp_call_tool_result_to_semantic_kernel_type( +@experimental +def _mcp_call_tool_result_to_semantic_kernel_type( mcp_type: CallToolResult, ) -> list[TextContent | ImageContent | BinaryContent]: """Convert a MCP container type to a Semantic Kernel type.""" - return [mcp_type_to_semantic_kernel_type(item) for item in mcp_type.content] + return [_mcp_type_to_semantic_kernel_type(item) for item in mcp_type.content] -def mcp_type_to_semantic_kernel_type( +@experimental +def _mcp_type_to_semantic_kernel_type( mcp_type: MCPImageContent | MCPTextContent | EmbeddedResource, ) -> TextContent | ImageContent | BinaryContent: """Convert a MCP type to a Semantic Kernel type.""" @@ -76,29 +80,114 @@ def mcp_type_to_semantic_kernel_type( ) -class MCPServerConfig(BaseModel): - """MCP server configuration.""" +@experimental +def get_parameter_from_mcp_prompt(prompt: Prompt) -> list[dict[str, Any]]: + """Creates a MCPFunction instance from a prompt.""" + # Check if 'properties' is missing or not a dictionary + if not prompt.arguments: + return [] + return [ + dict( + name=prompt_argument.name, + description=prompt_argument.description, + is_required=True, + type_object=str, + ) + for prompt_argument in prompt.arguments + ] - model_config = ConfigDict( - populate_by_name=True, arbitrary_types_allowed=True, validate_assignment=True, extra="allow" - ) - session: ClientSession | None = None +@experimental +def get_parameters_from_mcp_tool(tool: Tool) -> list[dict[str, Any]]: + """Creates an MCPFunction instance from a tool.""" + properties = tool.inputSchema.get("properties", None) + required = tool.inputSchema.get("required", None) + # Check if 'properties' is missing or not a dictionary + if not properties: + return [] + return [ + dict( + name=prop_name, + is_required=prop_name in required, + type=prop_details.get("type"), + default_value=prop_details.get("default", None), + schema_data=prop_details, + ) + for prop_name, prop_details in properties.items() + ] - @asynccontextmanager - async def get_session(self): - """Get or Open an MCP session.""" - try: - if self.session is None: - # If the session is not open, create always new one - async with self.get_mcp_client() as (read, write), ClientSession(read, write) as session: - await session.initialize() - yield session - else: - # If the session is set by the user, just yield it - yield self.session - except Exception as ex: - raise KernelPluginInvalidConfigurationError("Failed establish MCP session.") from ex + +@experimental +class MCPPluginBase: + """MCP Plugin Base.""" + + def __init__( + self, + name: str, + description: str | None = None, + session: ClientSession | None = None, + ) -> None: + """Initialize the MCP Plugin Base.""" + self.name = name + self.description = description + self._tools_parsed = False + self._prompts_parsed = False + self._exit_stack = AsyncExitStack() + self.session = session + + async def connect(self, force_reload: bool = False) -> None: + """Connect to the MCP server.""" + if not self.session: + try: + transport = await self._exit_stack.enter_async_context(self.get_mcp_client()) + except Exception as ex: + await self._exit_stack.aclose() + raise KernelPluginInvalidConfigurationError( + "Failed to connect to the MCP server. Please check your configuration." + ) from ex + try: + session = await self._exit_stack.enter_async_context(ClientSession(*transport)) + except Exception as ex: + await self._exit_stack.aclose() + raise KernelPluginInvalidConfigurationError( + "Failed to create a session. Please check your configuration." + ) from ex + await session.initialize() + self.session = session + elif self.session._request_id == 0: + # If the session is not initialized, we need to reinitialize it + await self.session.initialize() + if not self._tools_parsed or force_reload: + try: + tool_list = await self.session.list_tools() + except Exception: + tool_list = None + # Create methods with the kernel_function decorator for each tool + for tool in tool_list.tools if tool_list else []: + func = kernel_function(name=tool.name, description=tool.description)(partial(self.call_tool, tool.name)) + func.__kernel_function_parameters__ = get_parameters_from_mcp_tool(tool) + setattr(self, tool.name, func) + + self._tools_parsed = True # Mark tools as parsed + + if not self._prompts_parsed or force_reload: + try: + prompt_list = await self.session.list_prompts() + except Exception: + prompt_list = None + for prompt in prompt_list.prompts if prompt_list else []: + func = kernel_function(name=prompt.name, description=prompt.description)( + partial(self.get_prompt, prompt.name) + ) + func.__kernel_function_parameters__ = get_parameter_from_mcp_prompt(prompt) + setattr(self, prompt.name, func) + + self._prompts_parsed = True + + async def close(self) -> None: + """Disconnect from the MCP server.""" + await self._exit_stack.aclose() + self.session = None @abstractmethod def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: @@ -107,11 +196,14 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: async def call_tool(self, tool_name: str, **kwargs: Any) -> list[TextContent | ImageContent | BinaryContent]: """Call a tool with the given arguments.""" + if not self.session: + raise KernelPluginInvalidConfigurationError( + "MCP server not connected, please call connect() before using this method." + ) try: - async with self.get_session() as session: - return mcp_call_tool_result_to_semantic_kernel_type( - await session.call_tool(tool_name, arguments=kwargs) - ) + return _mcp_call_tool_result_to_semantic_kernel_type( + await self.session.call_tool(tool_name, arguments=kwargs) + ) except McpError: raise except Exception as ex: @@ -119,39 +211,74 @@ async def call_tool(self, tool_name: str, **kwargs: Any) -> list[TextContent | I async def get_prompt(self, prompt_name: str, **kwargs: Any) -> list[ChatMessageContent]: """Call a prompt with the given arguments.""" + if not self.session: + raise KernelPluginInvalidConfigurationError( + "MCP server not connected, please call connect() before using this method." + ) try: - async with self.get_session() as session: - prompt_result = await session.get_prompt(prompt_name, arguments=kwargs) - return [mcp_prompt_message_to_semantic_kernel_type(message) for message in prompt_result.messages] + prompt_result = await self.session.get_prompt(prompt_name, arguments=kwargs) + return [_mcp_prompt_message_to_semantic_kernel_type(message) for message in prompt_result.messages] except McpError: raise except Exception as ex: raise FunctionExecutionException(f"Failed to call prompt '{prompt_name}'.") from ex - -class MCPStdioServerConfig(MCPServerConfig): - """MCP stdio server configuration. - - The arguments are used to create a StdioServerParameters object. - Which is then used to create a stdio client. - see mcp.client.stdio.stdio_client and mcp.client.stdio.stdio_server_parameters - for more details. - - Any extra arguments passed to the constructor will be passed to the - StdioServerParameters constructor. - - Args: - command: The command to run the MCP server. - args: The arguments to pass to the command. - env: The environment variables to set for the command. - encoding: The encoding to use for the command output. - - """ - - command: str - args: list[str] = Field(default_factory=list) - env: dict[str, str] | None = None - encoding: str | None = None + async def __aenter__(self) -> Self: + """Enter the context manager.""" + try: + await self.connect() + return self + except KernelPluginInvalidConfigurationError: + raise + except Exception as ex: + await self._exit_stack.aclose() + raise FunctionExecutionException("Failed to enter context manager.") from ex + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: Any + ) -> None: + """Exit the context manager.""" + await self.close() + + +class MCPStdioPlugin(MCPPluginBase): + """MCP stdio server configuration.""" + + def __init__( + self, + name: str, + command: str, + session: ClientSession | None = None, + description: str | None = None, + args: list[str] | None = None, + env: dict[str, str] | None = None, + encoding: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize the MCP stdio plugin. + + The arguments are used to create a StdioServerParameters object. + Which is then used to create a stdio client. + see mcp.client.stdio.stdio_client and mcp.client.stdio.stdio_server_parameters + for more details. + + Args: + name: The name of the plugin. + command: The command to run the MCP server. + session: The session to use for the MCP connection. + description: The description of the plugin. + args: The arguments to pass to the command. + env: The environment variables to set for the command. + encoding: The encoding to use for the command output. + kwargs: Any extra arguments to pass to the stdio client. + + """ + super().__init__(name, description, session) + self.command = command + self.args = args or [] + self.env = env + self.encoding = encoding + self._client_kwargs = kwargs def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: """Get an MCP stdio client.""" @@ -162,32 +289,50 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: } if self.encoding: args["encoding"] = self.encoding - if self.model_extra: - args.update(self.model_extra) + if self._client_kwargs: + args.update(self._client_kwargs) return stdio_client(server=StdioServerParameters(**args)) -class MCPSseServerConfig(MCPServerConfig): - """MCP sse server configuration. - - The arguments are used to create a sse client. - see mcp.client.sse.sse_client for more details. - - Any extra arguments passed to the constructor will be passed to the - sse client constructor. - - Args: - url: The URL of the MCP server. - headers: The headers to send with the request. - timeout: The timeout for the request. - sse_read_timeout: The timeout for reading from the SSE stream. - - """ - - url: str - headers: dict[str, Any] | None = None - timeout: float | None = None - sse_read_timeout: float | None = None +class MCPSsePlugin(MCPPluginBase): + """MCP sse server configuration.""" + + def __init__( + self, + name: str, + url: str, + session: ClientSession | None = None, + description: str | None = None, + headers: dict[str, Any] | None = None, + timeout: float | None = None, + sse_read_timeout: float | None = None, + **kwargs: Any, + ) -> None: + """Initialize the MCP sse plugin. + + The arguments are used to create a sse client. + see mcp.client.sse.sse_client for more details. + + Any extra arguments passed to the constructor will be passed to the + sse client constructor. + + Args: + name: The name of the plugin. + url: The URL of the MCP server. + session: The session to use for the MCP connection. + description: The description of the plugin. + headers: The headers to send with the request. + timeout: The timeout for the request. + sse_read_timeout: The timeout for reading from the SSE stream. + kwargs: Any extra arguments to pass to the sse client. + + """ + super().__init__(name, description, session) + self.url = url + self.headers = headers or {} + self.timeout = timeout + self.sse_read_timeout = sse_read_timeout + self._client_kwargs = kwargs def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: """Get an MCP SSE client.""" @@ -200,150 +345,6 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: args["timeout"] = self.timeout if self.sse_read_timeout is not None: args["sse_read_timeout"] = self.sse_read_timeout - if self.model_extra: - args.update(self.model_extra) + if self._client_kwargs: + args.update(self._client_kwargs) return sse_client(**args) - - -@experimental -def get_parameter_from_mcp_prompt(prompt: Prompt) -> list[KernelParameterMetadata]: - """Creates a MCPFunction instance from a prompt.""" - # Check if 'properties' is missing or not a dictionary - if not prompt.arguments: - return [] - return [ - KernelParameterMetadata( - name=prompt_argument.name, - description=prompt_argument.description, - is_required=True, - type_object=str, - ) - for prompt_argument in prompt.arguments - ] - - -@experimental -def get_parameters_from_mcp_tool(tool: Tool) -> list[KernelParameterMetadata]: - """Creates an MCPFunction instance from a tool.""" - properties = tool.inputSchema.get("properties", None) - required = tool.inputSchema.get("required", None) - # Check if 'properties' is missing or not a dictionary - if not properties: - return [] - return [ - KernelParameterMetadata( - name=prop_name, - is_required=prop_name in required, - type=prop_details.get("type"), - default_value=prop_details.get("default", None), - schema_data=prop_details, - ) - for prop_name, prop_details in properties.items() - ] - - -@experimental -async def create_plugin_from_mcp_server( - plugin_name: str, - description: str | None = None, - server_config: MCPServerConfig | None = None, - **kwargs: Any, -) -> tuple[KernelPlugin, MCPServerConfig]: - """Creates a KernelPlugin from a MCP server config. - - Args: - plugin_name: The name of the plugin. - description: The description of the plugin. - server_config: The MCP client to use for communication, - should be a MCPStdioServerConfig or MCPSseServerConfig. - If not supplied, it will be created from the kwargs. - kwargs: Any extra arguments to pass to the plugin creation. - - Returns: - KernelPlugin: The created plugin, this should then be passed to the kernel or a agent. - MCPServerConfig: The server config used to create the plugin. - - """ - if server_config is None: - if "url" in kwargs: - try: - server_config = MCPSseServerConfig(**kwargs) - except Exception as e: - raise KernelPluginInvalidConfigurationError( - f"Failed to create MCPSseServerConfig with args: {kwargs}" - ) from e - elif "command" in kwargs: - try: - server_config = MCPStdioServerConfig(**kwargs) - except Exception as e: - raise KernelPluginInvalidConfigurationError( - f"Failed to create MCPStdioServerConfig with args: {kwargs}" - ) from e - if server_config is None: - raise KernelPluginInvalidConfigurationError( - "Failed to create MCP server configuration, please provide a valid server_config or kwargs." - ) - async with server_config.get_session() as session: - try: - tool_list = await session.list_tools() - except Exception: - tool_list = None - tools = [ - KernelFunctionFromMethod( - method=kernel_function(name=tool.name, description=tool.description)( - partial(server_config.call_tool, tool.name) - ), - parameters=get_parameters_from_mcp_tool(tool), - ) - for tool in (tool_list.tools if tool_list else []) - ] - try: - prompt_list = await session.list_prompts() - except Exception: - prompt_list = None - prompts = [ - KernelFunctionFromMethod( - method=kernel_function(name=prompt.name, description=prompt.description)( - partial(server_config.get_prompt, prompt.name) - ), - parameters=get_parameter_from_mcp_prompt(prompt), - ) - for prompt in (prompt_list.prompts if prompt_list else []) - ] - return (KernelPlugin(name=plugin_name, description=description, functions=tools + prompts), server_config) - - -@asynccontextmanager -async def mcp_server_as_plugin( - plugin_name: str, - description: str | None = None, - server_config: MCPServerConfig | None = None, - **kwargs: Any, -) -> AsyncGenerator[KernelPlugin, None]: - """Creates a KernelPlugin from a MCP server config. - - Args: - plugin_name: The name of the plugin. - description: The description of the plugin. - server_config: The MCP client to use for communication, - should be a MCPStdioServerConfig or MCPSseServerConfig. - If not supplied, it will be created from the kwargs. - kwargs: Any extra arguments to pass to the plugin creation. - - Yields: - KernelPlugin: The created plugin, this should then be passed to the kernel or a agent. - - """ - server = None - try: - plugin, server = await create_plugin_from_mcp_server( - plugin_name=plugin_name, - description=description, - server_config=server_config, - **kwargs, - ) - yield plugin - finally: - # Close the session if it was created in this context - if server and server.session: - await server.session.__aexit__() diff --git a/python/semantic_kernel/functions/kernel_plugin.py b/python/semantic_kernel/functions/kernel_plugin.py index 8bd85e20ec12..aa9ff8a4a64a 100644 --- a/python/semantic_kernel/functions/kernel_plugin.py +++ b/python/semantic_kernel/functions/kernel_plugin.py @@ -239,12 +239,15 @@ def from_object( else: candidates = inspect.getmembers(plugin_instance, inspect.ismethod) candidates.extend(inspect.getmembers(plugin_instance, inspect.isfunction)) # type: ignore + candidates.extend(inspect.getmembers(plugin_instance, inspect.iscoroutinefunction)) # type: ignore # Read every method from the plugin instance functions = [ KernelFunctionFromMethod(method=candidate, plugin_name=plugin_name) for _, candidate in candidates if hasattr(candidate, "__kernel_function__") ] + if not description: + description = getattr(plugin_instance, "description", None) return cls(name=plugin_name, description=description, functions=functions) @classmethod diff --git a/python/tests/integration/mcp/test_mcp_integration.py b/python/tests/integration/mcp/test_mcp_integration.py index a2ab740b8485..e6fee3d4abfd 100644 --- a/python/tests/integration/mcp/test_mcp_integration.py +++ b/python/tests/integration/mcp/test_mcp_integration.py @@ -4,7 +4,7 @@ import os from typing import TYPE_CHECKING -from semantic_kernel.connectors.mcp import MCPStdioServerConfig, create_plugin_from_mcp_server +from semantic_kernel.connectors.mcp import MCPStdioPlugin from semantic_kernel.functions.kernel_arguments import KernelArguments if TYPE_CHECKING: @@ -12,27 +12,25 @@ async def test_from_mcp(kernel: "Kernel"): - mcp_server_path = os.path.join(os.path.dirname(__file__), "../../assets/test_plugins", "TestMCPPlugin") - mcp_server_file = "mcp_server.py" - config = MCPStdioServerConfig( - command="uv", - args=["--directory", mcp_server_path, "run", mcp_server_file], + mcp_server_path = os.path.join( + os.path.dirname(__file__), "../../assets/test_plugins", "TestMCPPlugin", "mcp_server.py" ) + async with MCPStdioPlugin( + name="TestMCPPlugin", + command="python", + args=[mcp_server_path], + ) as plugin: + assert plugin is not None + assert plugin.name == "TestMCPPlugin" - plugin = await create_plugin_from_mcp_server( - plugin_name="TestMCPPlugin", - description="Test MCP Plugin", - server_config=config, - ) - - assert plugin is not None - assert plugin.name == "TestMCPPlugin" - assert len(plugin.functions) == 2 + loaded_plugin = kernel.add_plugin(plugin) - kernel.add_plugin(plugin) + assert loaded_plugin is not None + assert loaded_plugin.name == "TestMCPPlugin" + assert len(loaded_plugin.functions) == 2 - result = await plugin.functions["echo_tool"].invoke(kernel, arguments=KernelArguments(message="test")) - assert "Tool echo: test" in result.value[0].text + result = await loaded_plugin.functions["echo_tool"].invoke(kernel, arguments=KernelArguments(message="test")) + assert "Tool echo: test" in result.value[0].text - result = await plugin.functions["echo_prompt"].invoke(kernel, arguments=KernelArguments(message="test")) - assert "test" in result.value[0].content + result = await loaded_plugin.functions["echo_prompt"].invoke(kernel, arguments=KernelArguments(message="test")) + assert "test" in result.value[0].content diff --git a/python/tests/unit/connectors/mcp/test_mcp.py b/python/tests/unit/connectors/mcp/test_mcp.py index 54ae5d3a7d24..70c7f8c7dfc8 100644 --- a/python/tests/unit/connectors/mcp/test_mcp.py +++ b/python/tests/unit/connectors/mcp/test_mcp.py @@ -1,16 +1,19 @@ # Copyright (c) Microsoft. All rights reserved. -from unittest.mock import MagicMock, patch +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock, patch import pytest from mcp import ClientSession, ListToolsResult, StdioServerParameters, Tool from semantic_kernel.connectors.mcp import ( - MCPSseServerConfig, - MCPStdioServerConfig, - create_plugin_from_mcp_server, + MCPSsePlugin, + MCPStdioPlugin, ) from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError +if TYPE_CHECKING: + from semantic_kernel import Kernel + @pytest.fixture def list_tool_calls() -> ListToolsResult: @@ -35,67 +38,26 @@ def list_tool_calls() -> ListToolsResult: ) -async def test_mcp_server_config_session_initialize(): +async def test_mcp_plugin_session_not_initialize(): # Test if Client can insert it's own Session - mock_session = MagicMock(spec=ClientSession) - config = MCPSseServerConfig(session=mock_session, url="http://localhost:8080/sse") - async with config.get_session() as session: - assert session is mock_session - - -async def test_mcp_sse_server_config_get_session(): - # Patch both the `ClientSession` and `sse_client` independently - with ( - patch("semantic_kernel.connectors.mcp.ClientSession") as mock_client_session, - patch("semantic_kernel.connectors.mcp.sse_client") as mock_sse_client, - ): - mock_read = MagicMock() - mock_write = MagicMock() - - mock_generator = MagicMock() - # Make the mock_sse_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_sse_client return an AsyncMock for the context manager - mock_sse_client.return_value = mock_generator - - settings = MCPSseServerConfig(url="http://localhost:8080/sse") + mock_session = AsyncMock(spec=ClientSession) + mock_session._request_id = 0 + async with MCPSsePlugin(name="test", session=mock_session, url="http://localhost:8080/sse") as plugin: + assert plugin.session is mock_session + assert mock_session.initialize.called_once - # Test the `get_session` method with ClientSession mock - async with settings.get_session() as session: - assert session == mock_client_session - -async def test_mcp_stdio_server_config_get_session(): - # Patch both the `ClientSession` and `sse_client` independently - with ( - patch("semantic_kernel.connectors.mcp.ClientSession") as mock_client_session, - 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__.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_stdio_client.return_value = mock_generator - - settings = MCPStdioServerConfig( - command="echo", - args=["Hello"], - ) - - # Test the `get_session` method with ClientSession mock - async with settings.get_session() as session: - assert session == mock_client_session +async def test_mcp_plugin_session_initialized(): + # Test if Client can insert it's own Session + mock_session = AsyncMock(spec=ClientSession) + mock_session._request_id = 1 + mock_session.initialize = AsyncMock() + async with MCPSsePlugin(name="test", session=mock_session, url="http://localhost:8080/sse") as plugin: + assert plugin.session is mock_session + assert not mock_session.initialize.called -async def test_mcp_stdio_server_config_failed_get_session(): - # Patch both the `ClientSession` and `stdio_client` independently +async def test_mcp_plugin_failed_get_session(): with ( patch("semantic_kernel.connectors.mcp.stdio_client") as mock_stdio_client, ): @@ -110,20 +72,18 @@ async def test_mcp_stdio_server_config_failed_get_session(): # Make the mock_stdio_client return an AsyncMock for the context manager mock_stdio_client.return_value = mock_generator - settings = MCPStdioServerConfig( - command="echo", - args=["Hello"], - ) - - # Test the `get_session` method with ClientSession mock and expect an exception with pytest.raises(KernelPluginInvalidConfigurationError): - async with settings.get_session(): + 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): +async def test_with_kwargs_stdio(mock_session, mock_client, list_tool_calls, kernel: "Kernel"): mock_read = MagicMock() mock_write = MagicMock() @@ -135,28 +95,29 @@ async def test_with_kwargs_stdio(mock_session, mock_client, list_tool_calls): # 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 - plugin = await create_plugin_from_mcp_server( - plugin_name="TestMCPPlugin", + async with MCPStdioPlugin( + name="TestMCPPlugin", description="Test MCP Plugin", command="uv", args=["--directory", "path", "run", "file.py"], - ) - mock_client.assert_called_once_with( - server=StdioServerParameters(command="uv", args=["--directory", "path", "run", "file.py"]) - ) - assert plugin is not None - assert plugin.name == "TestMCPPlugin" - assert plugin.description == "Test MCP Plugin" - assert plugin.functions.get("func1") is not None - assert plugin.functions["func1"].parameters[0].name == "name" - assert plugin.functions["func1"].parameters[0].is_required - assert plugin.functions.get("func2") is not None - assert len(plugin.functions["func2"].parameters) == 0 + ) 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.sse_client") @patch("semantic_kernel.connectors.mcp.ClientSession") -async def test_with_kwargs_sse(mock_session, mock_client, list_tool_calls): +async def test_with_kwargs_sse(mock_session, mock_client, list_tool_calls, kernel: "Kernel"): mock_read = MagicMock() mock_write = MagicMock() @@ -168,17 +129,18 @@ async def test_with_kwargs_sse(mock_session, mock_client, list_tool_calls): # 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 - plugin = await create_plugin_from_mcp_server( - plugin_name="TestMCPPlugin", + async with MCPSsePlugin( + name="TestMCPPlugin", description="Test MCP Plugin", url="http://localhost:8080/sse", - ) - mock_client.assert_called_once_with(url="http://localhost:8080/sse") - assert plugin is not None - assert plugin.name == "TestMCPPlugin" - assert plugin.description == "Test MCP Plugin" - assert plugin.functions.get("func1") is not None - assert plugin.functions["func1"].parameters[0].name == "name" - assert plugin.functions["func1"].parameters[0].is_required - assert plugin.functions.get("func2") is not None - assert len(plugin.functions["func2"].parameters) == 0 + ) 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 From b4e680143e5e68ff2934f32131ff6cd8c2372c3c Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 3 Apr 2025 11:37:49 +0200 Subject: [PATCH 13/19] add readme --- python/samples/concepts/mcp/README.md | 39 +++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 python/samples/concepts/mcp/README.md diff --git a/python/samples/concepts/mcp/README.md b/python/samples/concepts/mcp/README.md new file mode 100644 index 000000000000..18dda6561e9b --- /dev/null +++ b/python/samples/concepts/mcp/README.md @@ -0,0 +1,39 @@ +# Model Context Protocol + +The model context protocol is a standard created by Anthropic to allow models to share context with each other. See the [official documentation](https://modelcontextprotocol.io/introduction) for more information. + +It consists of clients and servers, and servers can be hosted locally, or they can be exposed as a online API. + +Our goal is that Semantic Kernel can act as both a client and a server. + +In this folder the client side of things is demonstrated. It takes the definition of a server and uses that to create a Semantic Kernel plugin, this plugin exposes the tools and prompts of the server as functions in the kernel. + +Those can then be used with function calling in a chat or agent. + +## Server types + +There are two types of servers, Stdio and Sse based. The sample shows how to use the Stdio based server, which get's run locally, in this case by using [npx](https://docs.npmjs.com/cli/v8/commands/npx). + +Some other common runners are [uvx](https://docs.astral.sh/uv/guides/tools/), for python servers and [docker](https://www.docker.com/), for containerized servers. + +The code shown works the same for a Sse server, only then a MCPSsePlugin needs to be used instead of the MCPStdioPlugin. + +The reverse, using Semantic Kernel as a server, is not yet implemented, but will be in the future. + +## Running the sample + +1. Make sure you have the [Node.js](https://nodejs.org/en/download/) installed. +2. Make sure you have the [npx](https://docs.npmjs.com/cli/v8/commands/npx) available in PATH. +3. The Github MCP Server uses a Github Personal Access Token (PAT) to authenticate, see [the documentation](https://github.com/modelcontextprotocol/servers/tree/main/src/github) on how to create one. +4. Install Semantic Kernel with the mcp extra: + +```bash +pip install semantic-kernel[mcp] +``` + +6. Run the sample: + +```bash +cd samples/concepts/mcp +python mcp_as_plugin.py +``` From 5288476b9b082b0d06489c159241b0b8e360b01c Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 3 Apr 2025 11:38:59 +0200 Subject: [PATCH 14/19] updated numbering --- python/samples/concepts/mcp/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/samples/concepts/mcp/README.md b/python/samples/concepts/mcp/README.md index 18dda6561e9b..0a452af50611 100644 --- a/python/samples/concepts/mcp/README.md +++ b/python/samples/concepts/mcp/README.md @@ -31,9 +31,9 @@ The reverse, using Semantic Kernel as a server, is not yet implemented, but will pip install semantic-kernel[mcp] ``` -6. Run the sample: +5. Run the sample: ```bash -cd samples/concepts/mcp +cd python/samples/concepts/mcp python mcp_as_plugin.py ``` From 18ae9f88de5e4f6c17fe1c2f18425e8280ab2eeb Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 3 Apr 2025 11:42:37 +0200 Subject: [PATCH 15/19] fixed assert --- python/tests/unit/connectors/mcp/test_mcp.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/tests/unit/connectors/mcp/test_mcp.py b/python/tests/unit/connectors/mcp/test_mcp.py index 70c7f8c7dfc8..10327476ebb6 100644 --- a/python/tests/unit/connectors/mcp/test_mcp.py +++ b/python/tests/unit/connectors/mcp/test_mcp.py @@ -42,13 +42,14 @@ async def test_mcp_plugin_session_not_initialize(): # 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 MCPSsePlugin(name="test", session=mock_session, url="http://localhost:8080/sse") as plugin: assert plugin.session is mock_session assert mock_session.initialize.called_once async def test_mcp_plugin_session_initialized(): - # Test if Client can insert it's own Session + # Test if Client can insert it's own initialized Session mock_session = AsyncMock(spec=ClientSession) mock_session._request_id = 1 mock_session.initialize = AsyncMock() From 9a55c218149c896d7347a299ef2992ed6b1c3988 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 3 Apr 2025 12:02:27 +0200 Subject: [PATCH 16/19] updated redis --- python/pyproject.toml | 2 +- python/tests/unit/connectors/mcp/test_mcp.py | 2 +- python/uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index a1e5eb60fb6f..78491d3f5f88 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -117,7 +117,7 @@ qdrant = [ redis = [ "redis[hiredis] ~= 5.0", "types-redis ~= 4.6.0.20240425", - "redisvl >= 0.3.6", + "redisvl ~= 0.4" ] usearch = [ "usearch ~= 2.16", diff --git a/python/tests/unit/connectors/mcp/test_mcp.py b/python/tests/unit/connectors/mcp/test_mcp.py index 10327476ebb6..6cd015785f3c 100644 --- a/python/tests/unit/connectors/mcp/test_mcp.py +++ b/python/tests/unit/connectors/mcp/test_mcp.py @@ -45,7 +45,7 @@ async def test_mcp_plugin_session_not_initialize(): mock_session.initialize = AsyncMock() async with MCPSsePlugin(name="test", session=mock_session, url="http://localhost:8080/sse") as plugin: assert plugin.session is mock_session - assert mock_session.initialize.called_once + assert mock_session.initialize.called async def test_mcp_plugin_session_initialized(): diff --git a/python/uv.lock b/python/uv.lock index c13ea94982ed..0f727bc1e318 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -5381,7 +5381,7 @@ requires-dist = [ { name = "pyodbc", marker = "extra == 'sql'", specifier = ">=5.2" }, { name = "qdrant-client", marker = "extra == 'qdrant'", specifier = "~=1.9" }, { name = "redis", extras = ["hiredis"], marker = "extra == 'redis'", specifier = "~=5.0" }, - { name = "redisvl", marker = "extra == 'redis'", specifier = ">=0.3.6" }, + { name = "redisvl", marker = "extra == 'redis'", specifier = "~=0.4" }, { name = "scipy", specifier = ">=1.15.1" }, { name = "sentence-transformers", marker = "extra == 'hugging-face'", specifier = ">=2.2,<5.0" }, { name = "torch", marker = "extra == 'hugging-face'", specifier = "==2.6.0" }, From ae33ebf061c6fce15f44474b73f781d9a4ed649e Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 3 Apr 2025 14:27:38 +0200 Subject: [PATCH 17/19] added agent sample --- python/samples/concepts/mcp/README.md | 7 ++ .../concepts/mcp/agent_with_mcp_plugin.py | 119 ++++++++++++++++++ python/samples/concepts/mcp/mcp_as_plugin.py | 13 +- 3 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 python/samples/concepts/mcp/agent_with_mcp_plugin.py diff --git a/python/samples/concepts/mcp/README.md b/python/samples/concepts/mcp/README.md index 0a452af50611..62fff45a4350 100644 --- a/python/samples/concepts/mcp/README.md +++ b/python/samples/concepts/mcp/README.md @@ -37,3 +37,10 @@ pip install semantic-kernel[mcp] cd python/samples/concepts/mcp python mcp_as_plugin.py ``` + +or: + +```bash +cd python/samples/concepts/mcp +python agent_with_mcp_plugin.py +``` diff --git a/python/samples/concepts/mcp/agent_with_mcp_plugin.py b/python/samples/concepts/mcp/agent_with_mcp_plugin.py new file mode 100644 index 000000000000..7fc85b15ca9b --- /dev/null +++ b/python/samples/concepts/mcp/agent_with_mcp_plugin.py @@ -0,0 +1,119 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from semantic_kernel.agents import ChatCompletionAgent, ChatHistoryAgentThread +from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion +from semantic_kernel.connectors.mcp import MCPStdioPlugin + +""" +The following sample demonstrates how to create a chat completion agent that +answers questions about Github using a Semantic Kernel Plugin from a MCP server. +The Chat Completion Service is passed directly via the ChatCompletionAgent constructor. +Additionally, the plugin is supplied via the constructor. +""" + + +# Simulate a conversation with the agent +USER_INPUTS = [ + "What are the latest 5 python issues in Microsoft/semantic-kernel?", + "Are there any untriaged python issues?", + "What is the status of issue #10785?", +] + + +async def main(): + # 1. Create the agent + async with MCPStdioPlugin( + name="Github", + description="Github Plugin", + command="npx", + args=["-y", "@modelcontextprotocol/server-github"], + ) as github_plugin: + agent = ChatCompletionAgent( + service=AzureChatCompletion(), + name="IssueAgent", + instructions="Answer questions about the Microsoft semantic-kernel github project.", + plugins=[github_plugin], + ) + + for user_input in USER_INPUTS: + # 2. Create a thread to hold the conversation + # If no thread is provided, a new thread will be + # created and returned with the initial response + thread: ChatHistoryAgentThread = None + + print(f"# User: {user_input}") + # 4. Invoke the agent for a response + response = await agent.get_response(messages=user_input, thread=thread) + print(f"# {response.name}: {response} ") + thread = response.thread + + # 4. Cleanup: Clear the thread + await thread.delete() if thread else None + + """ + Sample output: +GitHub MCP Server running on stdio +# User: What are the latest 5 python issues in Microsoft/semantic-kernel? +# IssueAgent: Here are the latest 5 Python issues in the +[Microsoft/semantic-kernel](https://github.com/microsoft/semantic-kernel) repository: + +1. **[Issue #11358](https://github.com/microsoft/semantic-kernel/pull/11358)** + **Title:** Python: Bump Python version to 1.27.0 for a release. + **Created by:** [moonbox3](https://github.com/moonbox3) + **Created at:** April 3, 2025 + **State:** Open + **Comments:** 1 + **Description:** Bump Python version to 1.27.0 for a release. + +2. **[Issue #11357](https://github.com/microsoft/semantic-kernel/pull/11357)** + **Title:** .Net: Version 1.45.0 + **Created by:** [markwallace-microsoft](https://github.com/markwallace-microsoft) + **Created at:** April 3, 2025 + **State:** Open + **Comments:** 0 + **Description:** Version bump for release 1.45.0. + +3. **[Issue #11356](https://github.com/microsoft/semantic-kernel/pull/11356)** + **Title:** .Net: Fix bug in sqlite filter logic + **Created by:** [westey-m](https://github.com/westey-m) + **Created at:** April 3, 2025 + **State:** Open + **Comments:** 0 + **Description:** Fix bug in sqlite filter logic. + +4. **[Issue #11355](https://github.com/microsoft/semantic-kernel/issues/11355)** + **Title:** .Net: [MEVD] Validate that the collection generic key parameter corresponds to the model + **Created by:** [roji](https://github.com/roji) + **Created at:** April 3, 2025 + **State:** Open + **Comments:** 0 + **Description:** We currently have validation for the TKey generic type parameter passed to the collection type, + and we have validation for the key property type on the model. + +5. **[Issue #11354](https://github.com/microsoft/semantic-kernel/issues/11354)** + **Title:** .Net: How to add custom JsonSerializer on a builder level + **Created by:** [PawelStadnicki](https://github.com/PawelStadnicki) + **Created at:** April 3, 2025 + **State:** Open + **Comments:** 0 + **Description:** Inquiry about adding a custom JsonSerializer for handling F# types within the SDK. + +If you need more details about a specific issue, let me know! +# User: Are there any untriaged python issues? +# IssueAgent: There are no untriaged Python issues in the Microsoft semantic-kernel repository. +# User: What is the status of issue #10785? +# IssueAgent: The status of issue #10785 in the Microsoft Semantic Kernel repository is **open**. + +- **Title**: Port dotnet feature: Create MCP Sample +- **Created at**: March 4, 2025 +- **Comments**: 0 +- **Labels**: python + +You can view the issue [here](https://github.com/microsoft/semantic-kernel/issues/10785). + """ + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/concepts/mcp/mcp_as_plugin.py b/python/samples/concepts/mcp/mcp_as_plugin.py index 082f9f52e692..79835c672215 100644 --- a/python/samples/concepts/mcp/mcp_as_plugin.py +++ b/python/samples/concepts/mcp/mcp_as_plugin.py @@ -28,16 +28,9 @@ # System message defining the behavior and persona of the chat bot. system_message = """ -You are a chat bot. Your name is Mosscap and -you have one goal: figure out what people need. -Your full name, should you need to know it, is -Splendid Speckled Mosscap. You communicate -effectively, but you tend to answer with long -flowery prose. You are also a math wizard, -especially for adding and subtracting. -You also excel at joke telling, where your tone is often sarcastic. -Once you have the answer I am looking for, -you will return a full answer to me as soon as possible. +You are a chat bot. And you help users interact with Github. +You are especially good at answering questions about the Microsoft semantic-kernel project. +You can call functions to get the information you need. """ # Create and configure the kernel. From 480e6a427c4a77ce5b9a456b45a7c083685d75ce Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 3 Apr 2025 15:15:57 +0200 Subject: [PATCH 18/19] added websocket support --- python/semantic_kernel/connectors/mcp.py | 42 ++++++++++++++++++++ python/tests/unit/connectors/mcp/test_mcp.py | 36 +++++++++++++++-- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/python/semantic_kernel/connectors/mcp.py b/python/semantic_kernel/connectors/mcp.py index c558296521a5..795c813e6ad8 100644 --- a/python/semantic_kernel/connectors/mcp.py +++ b/python/semantic_kernel/connectors/mcp.py @@ -11,6 +11,7 @@ from mcp.client.session import ClientSession from mcp.client.sse import sse_client from mcp.client.stdio import StdioServerParameters, stdio_client +from mcp.client.websocket import websocket_client from mcp.types import CallToolResult, EmbeddedResource, Prompt, PromptMessage, TextResourceContents, Tool from mcp.types import ( ImageContent as MCPImageContent, @@ -348,3 +349,44 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: if self._client_kwargs: args.update(self._client_kwargs) return sse_client(**args) + + +class MCPWebsocketPlugin(MCPPluginBase): + """MCP websocket server configuration.""" + + def __init__( + self, + name: str, + url: str, + session: ClientSession | None = None, + description: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize the MCP websocket plugin. + + The arguments are used to create a websocket client. + see mcp.client.websocket.websocket_client for more details. + + Any extra arguments passed to the constructor will be passed to the + websocket client constructor. + + Args: + name: The name of the plugin. + url: The URL of the MCP server. + session: The session to use for the MCP connection. + description: The description of the plugin. + kwargs: Any extra arguments to pass to the websocket client. + + """ + super().__init__(name, description, session) + self.url = url + self._client_kwargs = kwargs + + def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: + """Get an MCP websocket client.""" + args: dict[str, Any] = { + "url": self.url, + } + if self._client_kwargs: + args.update(self._client_kwargs) + return websocket_client(**args) diff --git a/python/tests/unit/connectors/mcp/test_mcp.py b/python/tests/unit/connectors/mcp/test_mcp.py index 6cd015785f3c..e5da66b4eb15 100644 --- a/python/tests/unit/connectors/mcp/test_mcp.py +++ b/python/tests/unit/connectors/mcp/test_mcp.py @@ -5,10 +5,7 @@ import pytest from mcp import ClientSession, ListToolsResult, StdioServerParameters, Tool -from semantic_kernel.connectors.mcp import ( - MCPSsePlugin, - MCPStdioPlugin, -) +from semantic_kernel.connectors.mcp import MCPSsePlugin, MCPStdioPlugin, MCPWebsocketPlugin from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError if TYPE_CHECKING: @@ -116,6 +113,37 @@ async def test_with_kwargs_stdio(mock_session, mock_client, list_tool_calls, ker 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"): From a3b233cdc1063907ddf03c8587d9c0a33069ea8c Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 3 Apr 2025 19:48:49 +0200 Subject: [PATCH 19/19] comment fixes --- python/samples/concepts/mcp/agent_with_mcp_plugin.py | 2 +- python/semantic_kernel/connectors/mcp.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/samples/concepts/mcp/agent_with_mcp_plugin.py b/python/samples/concepts/mcp/agent_with_mcp_plugin.py index 7fc85b15ca9b..dd3ccba1fe55 100644 --- a/python/samples/concepts/mcp/agent_with_mcp_plugin.py +++ b/python/samples/concepts/mcp/agent_with_mcp_plugin.py @@ -44,7 +44,7 @@ async def main(): thread: ChatHistoryAgentThread = None print(f"# User: {user_input}") - # 4. Invoke the agent for a response + # 3. Invoke the agent for a response response = await agent.get_response(messages=user_input, thread=thread) print(f"# {response.name}: {response} ") thread = response.thread diff --git a/python/semantic_kernel/connectors/mcp.py b/python/semantic_kernel/connectors/mcp.py index 795c813e6ad8..ef5772fe29af 100644 --- a/python/semantic_kernel/connectors/mcp.py +++ b/python/semantic_kernel/connectors/mcp.py @@ -311,7 +311,7 @@ def __init__( ) -> None: """Initialize the MCP sse plugin. - The arguments are used to create a sse client. + The arguments are used to create a sse client. see mcp.client.sse.sse_client for more details. Any extra arguments passed to the constructor will be passed to the