Files
microsoft--semantic-kernel/python/semantic_kernel/connectors/mcp.py
T
wehub-resource-sync b957a53def
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:21:23 +08:00

1212 lines
50 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
import asyncio
import json
import logging
import re
import sys
from abc import abstractmethod
from collections.abc import Awaitable, Callable, Sequence
from contextlib import AbstractAsyncContextManager, AsyncExitStack, _AsyncGeneratorContextManager
from datetime import timedelta
from functools import partial
from itertools import chain
from typing import TYPE_CHECKING, Any
from mcp import types
from mcp.client.session import ClientSession
from mcp.client.sse import sse_client
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamablehttp_client
from mcp.client.websocket import websocket_client
from mcp.server.lowlevel import Server
from mcp.shared.context import RequestContext
from mcp.shared.exceptions import McpError
from mcp.shared.session import RequestResponder
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
from semantic_kernel.contents.audio_content import AudioContent
from semantic_kernel.contents.binary_content import BinaryContent
from semantic_kernel.contents.chat_history import ChatHistory
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 FunctionExecutionException, KernelPluginInvalidConfigurationError
from semantic_kernel.functions.function_result import FunctionResult
from semantic_kernel.functions.kernel_arguments import KernelArguments
from semantic_kernel.functions.kernel_function import KernelFunction
from semantic_kernel.functions.kernel_function_decorator import kernel_function
from semantic_kernel.functions.kernel_plugin import KernelPlugin
from semantic_kernel.kernel_types import OneOrMany, OptionalOneOrMany
from semantic_kernel.prompt_template.prompt_template_base import PromptTemplateBase
from semantic_kernel.utils.feature_stage_decorator import experimental
from ..contents.function_call_content import FunctionCallContent
from ..contents.function_result_content import FunctionResultContent
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
if TYPE_CHECKING:
from mcp.server.lowlevel.server import LifespanResultT
logger = logging.getLogger(__name__)
# region: Helpers
SamplingConsentCallback = Callable[[str, types.CreateMessageRequestParams], Awaitable[bool]]
LOG_LEVEL_MAPPING: dict[types.LoggingLevel, int] = {
"debug": logging.DEBUG,
"info": logging.INFO,
"notice": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
"critical": logging.CRITICAL,
"alert": logging.CRITICAL,
"emergency": logging.CRITICAL,
}
@experimental
def _mcp_prompt_message_to_kernel_content(
mcp_type: types.PromptMessage | types.SamplingMessage,
) -> ChatMessageContent:
"""Convert a MCP container type to a Semantic Kernel type."""
items = list(
chain(
*[_mcp_content_types_to_kernel_content(mcp_type.content)],
)
)
return ChatMessageContent(
role=AuthorRole(mcp_type.role),
items=items, # type: ignore
inner_content=mcp_type,
)
@experimental
def _mcp_call_tool_result_to_kernel_contents(
mcp_type: types.CallToolResult,
) -> list[TextContent | ImageContent | BinaryContent | AudioContent | FunctionResultContent | FunctionCallContent]:
"""Convert a MCP container type to a Semantic Kernel type."""
return list(chain(*[_mcp_content_types_to_kernel_content(item) for item in mcp_type.content]))
@experimental
def _mcp_content_types_to_kernel_content(
mcp_type: types.SamplingMessageContentBlock
| types.ContentBlock
| Sequence[types.SamplingMessageContentBlock | types.ContentBlock],
) -> list[TextContent | ImageContent | BinaryContent | AudioContent | FunctionCallContent | FunctionResultContent]:
"""Convert a MCP type to a Semantic Kernel type."""
if isinstance(mcp_type, Sequence):
return list(chain(*[_mcp_content_types_to_kernel_content(item) for item in mcp_type]))
if isinstance(mcp_type, types.TextContent):
return [TextContent(text=mcp_type.text, inner_content=mcp_type)]
if isinstance(mcp_type, types.ImageContent):
return [ImageContent(data=mcp_type.data, mime_type=mcp_type.mimeType, inner_content=mcp_type)]
if isinstance(mcp_type, types.AudioContent):
return [AudioContent(data=mcp_type.data, mime_type=mcp_type.mimeType, inner_content=mcp_type)]
if isinstance(mcp_type, types.ResourceLink):
return [
BinaryContent(
uri=mcp_type.uri, # type: ignore
mime_type=mcp_type.mimeType,
inner_content=mcp_type,
)
]
if isinstance(mcp_type, types.ToolUseContent):
return [
FunctionCallContent(inner_content=mcp_type, name=mcp_type.name, arguments=mcp_type.input, id=mcp_type.id)
]
if isinstance(mcp_type, types.ToolResultContent):
return [
FunctionResultContent(
inner_content=mcp_type,
name=mcp_type.type,
result=list(chain(*[_mcp_content_types_to_kernel_content(mcp_type.content)])),
call_id=mcp_type.toolUseId,
)
]
# subtypes of EmbeddedResource
if isinstance(mcp_type.resource, types.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 {},
)
]
@experimental
def _kernel_content_to_mcp_content_types(
content: TextContent | ImageContent | BinaryContent | AudioContent | ChatMessageContent,
) -> Sequence[types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource]:
"""Convert a kernel content type to a MCP type."""
if isinstance(content, TextContent):
return [types.TextContent(type="text", text=content.text)]
if isinstance(content, ImageContent):
return [types.ImageContent(type="image", data=content.data_string, mimeType=content.mime_type)]
if isinstance(content, AudioContent):
return [types.AudioContent(type="audio", data=content.data_string, mimeType=content.mime_type)]
if isinstance(content, BinaryContent):
return [
types.EmbeddedResource(
type="resource",
resource=types.BlobResourceContents(
blob=content.data_string, mimeType=content.mime_type, uri=content.uri or "sk://binary"
),
)
]
if isinstance(content, ChatMessageContent):
messages: list[types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource] = []
for item in content.items:
if isinstance(item, (TextContent, ImageContent, BinaryContent, AudioContent)):
messages.extend(_kernel_content_to_mcp_content_types(item))
else:
logger.debug("Unsupported content type: %s", type(item))
return messages
raise FunctionExecutionException(f"Unsupported content type: {type(content)}")
@experimental
def _get_parameter_dict_from_mcp_prompt(prompt: types.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 [
{
"name": prompt_argument.name,
"description": prompt_argument.description,
"is_required": True,
"type_object": str,
}
for prompt_argument in prompt.arguments
]
@experimental
def _get_parameter_dicts_from_mcp_tool(tool: types.Tool) -> list[dict[str, Any]]:
"""Creates an MCPFunction instance from a tool."""
properties = tool.inputSchema.get("properties", None)
required = tool.inputSchema.get("required", [])
# Check if 'properties' is missing or not a dictionary
if not properties:
return []
params = []
for prop_name, prop_details in properties.items():
prop_details = json.loads(prop_details) if isinstance(prop_details, str) else prop_details
params.append({
"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,
})
return params
@experimental
def _normalize_mcp_name(name: str) -> str:
"""Normalize MCP tool/prompt names to allowed identifier pattern (A-Za-z0-9_.-)."""
return re.sub(r"[^A-Za-z0-9_.-]", "-", name)
# region: MCP Plugin
@experimental
class MCPPluginBase:
"""MCP Plugin Base."""
def __init__(
self,
name: str,
description: str | None = None,
load_tools: bool = True,
load_prompts: bool = True,
session: ClientSession | None = None,
kernel: Kernel | None = None,
request_timeout: int | None = None,
sampling_consent_callback: SamplingConsentCallback | None = None,
sampling_auto_approve: bool = False,
) -> None:
"""Initialize the MCP Plugin Base.
Args:
name: The name of the plugin.
description: The description of the plugin.
load_tools: Whether to load tools from the MCP server.
load_prompts: Whether to load prompts from the MCP server.
session: The session to use for the MCP connection.
kernel: The kernel instance with one or more Chat Completion clients.
request_timeout: The default timeout used for all requests.
sampling_consent_callback: Optional callback for approving MCP sampling requests.
Receives the plugin name and MCP sampling request params. Return
False to deny the request. Takes precedence over sampling_auto_approve.
sampling_auto_approve: Whether to auto-approve MCP sampling requests when no
sampling_consent_callback is configured. Defaults to False, meaning sampling
requests are denied unless a consent callback is provided or this flag is set
to True. Set to True only when connecting to a trusted MCP server.
"""
self.name = name
self.description = description
self.load_tools_flag = load_tools
self.load_prompts_flag = load_prompts
self._exit_stack = AsyncExitStack()
self.session = session
self.kernel = kernel or None
self.request_timeout = request_timeout
self.sampling_consent_callback = sampling_consent_callback
self.sampling_auto_approve = sampling_auto_approve
self._sampling_auto_approved_warning_logged = False
self._mcp_reserved_attribute_names: set[str] | None = None
self._current_task: asyncio.Task | None = None
self._stop_event: asyncio.Event | None = None
async def __aenter__(self) -> Self:
"""Enter the context manager."""
await self.connect()
return self
async def __aexit__(
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: Any
) -> None:
"""Exit the context manager."""
await self.close()
async def connect(self) -> None:
"""Connect to the MCP server."""
ready_event = asyncio.Event()
try:
self._current_task = asyncio.create_task(self._inner_connect(ready_event))
await ready_event.wait()
except KernelPluginInvalidConfigurationError:
ready_event.clear()
raise
except Exception as ex:
ready_event.clear()
await self.close()
raise FunctionExecutionException("Failed to enter context manager.") from ex
async def close(self) -> None:
"""Disconnect from the MCP server."""
if self._stop_event:
# Signal the stop event, which asks the _inner_connect
# method to close the session with the exit stack
self._stop_event.set()
if self._current_task:
# After, the signal, we wait for it to close the exit stack.
await self._current_task
self._current_task = None
self.session = None
async def _inner_connect(self, ready_event: asyncio.Event) -> None:
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()
ready_event.set()
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(
read_stream=transport[0],
write_stream=transport[1],
read_timeout_seconds=timedelta(seconds=self.request_timeout) if self.request_timeout else None,
message_handler=self.message_handler,
logging_callback=self.logging_callback,
sampling_callback=self.sampling_callback,
)
)
except Exception as ex:
await self._exit_stack.aclose()
raise KernelPluginInvalidConfigurationError(
"Failed to create a session. Please check your configuration."
) from ex
try:
await session.initialize()
except Exception as ex:
await self._exit_stack.aclose()
raise KernelPluginInvalidConfigurationError(
"Failed to initialize session. Please check your configuration."
) from ex
self.session = session
elif self.session._request_id == 0:
# If the session is not initialized, we need to reinitialize it
await self.session.initialize()
logger.debug("Connected to MCP server: %s", self.session)
if self.load_tools_flag:
await self.load_tools()
if self.load_prompts_flag:
await self.load_prompts()
if logger.level != logging.NOTSET:
try:
await self.session.set_logging_level(
next(level for level, value in LOG_LEVEL_MAPPING.items() if value == logger.level)
)
except Exception:
logger.warning("Failed to set log level to %s", logger.level)
# Setting up is complete, will now signal the main loop that we are ready
ready_event.set()
# Create a stop event to signal the exit stack to close
self._stop_event = asyncio.Event()
await self._stop_event.wait()
try:
await self._exit_stack.aclose()
except Exception as e:
logger.exception("Error during exit stack close", exc_info=e)
pass
async def sampling_callback(
self, context: RequestContext[ClientSession, Any], params: types.CreateMessageRequestParams
) -> types.CreateMessageResult | types.ErrorData:
"""Callback function for sampling.
This function is called when the MCP server needs to get a message completed.
If a sampling consent callback is configured, it is called before forwarding the request to the configured
chat completion service. Returning False denies the request. If no callback is configured, requests are
denied unless sampling_auto_approve is set to True, in which case they are auto-approved and a warning is
logged.
"""
if self.sampling_consent_callback is None:
if not self.sampling_auto_approve:
logger.warning(
"MCP sampling request for plugin '%s' was denied because no sampling consent callback was "
"configured. Provide a sampling_consent_callback or set sampling_auto_approve=True to allow "
"sampling requests.",
self.name,
)
return types.ErrorData(
code=types.INTERNAL_ERROR,
message="Sampling denied: no consent callback configured.",
)
if not self._sampling_auto_approved_warning_logged:
logger.warning(
"MCP sampling request for plugin '%s' was auto-approved because sampling_auto_approve is "
"enabled and no sampling consent callback was configured.",
self.name,
)
self._sampling_auto_approved_warning_logged = True
elif not await self._is_sampling_approved(params):
return types.ErrorData(
code=types.INTERNAL_ERROR,
message="Sampling denied by policy.",
)
if not self.kernel or not self.kernel.services:
return types.ErrorData(
code=types.INTERNAL_ERROR,
message="No services in Kernel. Please set a kernel with one or more services.",
)
logger.debug("Sampling callback called with params: %s", params)
if params.modelPreferences is not None and params.modelPreferences.hints:
# TODO (eavanvalkenburg): deal with other parts of the modelPreferences concept
names = [hint.name for hint in params.modelPreferences.hints]
else:
names = ["default"]
for name in names:
service = self.kernel.get_service(name, ChatCompletionClientBase)
break
if not service:
service = self.kernel.get_service("default", ChatCompletionClientBase)
if not service:
return types.ErrorData(
code=types.INTERNAL_ERROR,
message="No Chat completion service found.",
)
completion_settings = service.get_prompt_execution_settings_class()()
if "temperature" in completion_settings.__class__.model_fields:
completion_settings.temperature = params.temperature # type: ignore
if "max_completion_tokens" in completion_settings.__class__.model_fields:
completion_settings.max_completion_tokens = params.maxTokens # type: ignore
elif "max_tokens" in completion_settings.__class__.model_fields:
completion_settings.max_tokens = params.maxTokens # type: ignore
elif "max_output_tokens" in completion_settings.__class__.model_fields:
completion_settings.max_output_tokens = params.maxTokens # type: ignore
chat_history = ChatHistory(system_message=params.systemPrompt)
for msg in params.messages:
chat_history.add_message(_mcp_prompt_message_to_kernel_content(msg))
try:
result = await service.get_chat_message_content(
chat_history,
completion_settings,
)
except Exception as ex:
return types.ErrorData(
code=types.INTERNAL_ERROR,
message=f"Failed to get chat message content: {ex}",
)
if not result:
return types.ErrorData(
code=types.INTERNAL_ERROR,
message="Failed to get chat message content.",
)
mcp_contents = _kernel_content_to_mcp_content_types(result)
# grab the first content that is of type TextContent or ImageContent
mcp_content = next(
(content for content in mcp_contents if isinstance(content, (types.TextContent, types.ImageContent))),
None,
)
if not mcp_content:
return types.ErrorData(
code=types.INTERNAL_ERROR,
message="Failed to get right content types from the response.",
)
return types.CreateMessageResult(
role="assistant",
content=mcp_content,
model=service.ai_model_id,
)
async def _is_sampling_approved(self, params: types.CreateMessageRequestParams) -> bool:
if self.sampling_consent_callback is None:
return True
try:
return await self.sampling_consent_callback(self.name, params)
except Exception:
logger.exception("MCP sampling consent callback failed for plugin '%s'.", self.name)
return False
async def logging_callback(self, params: types.LoggingMessageNotificationParams) -> None:
"""Callback function for logging.
This function is called when the MCP Server sends a log message.
By default it will log the message to the logger with the level set in the params.
Please subclass the MCP*Plugin and override this function if you want to adapt the behavior.
"""
logger.log(LOG_LEVEL_MAPPING[params.level], params.data)
async def message_handler(
self,
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
) -> None:
"""Handle messages from the MCP server.
By default this function will handle exceptions on the server, by logging those.
And it will trigger a reload of the tools and prompts when the list changed notification is received.
If you want to extend this behavior you can subclass the MCPPlugin and override this function,
if you want to keep the default behavior, make sure to call `super().message_handler(message)`.
"""
if isinstance(message, Exception):
logger.error("Error from MCP server: %s", message)
return
if isinstance(message, types.ServerNotification):
match message.root.method:
case "notifications/tools/list_changed":
await self.load_tools()
case "notifications/prompts/list_changed":
await self.load_prompts()
def _has_mcp_function_name_conflict(self, item_type: str, remote_name: str, local_name: str) -> bool:
if self._mcp_reserved_attribute_names is None:
self._mcp_reserved_attribute_names = set(dir(self))
if local_name not in self._mcp_reserved_attribute_names:
return False
logger.warning(
"Skipping MCP %s '%s' because normalized name '%s' conflicts with an existing plugin attribute.",
item_type,
remote_name,
local_name,
)
return True
async def load_prompts(self):
"""Load prompts from the MCP server."""
try:
prompt_list = await self.session.list_prompts()
except Exception:
prompt_list = None
for prompt in prompt_list.prompts if prompt_list else []:
local_name = _normalize_mcp_name(prompt.name)
if self._has_mcp_function_name_conflict("prompt", prompt.name, local_name):
continue
func = kernel_function(name=local_name, description=prompt.description)(
partial(self.get_prompt, prompt.name)
)
func.__kernel_function_parameters__ = _get_parameter_dict_from_mcp_prompt(prompt)
setattr(self, local_name, func)
async def load_tools(self):
"""Load tools from the MCP server."""
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 []:
local_name = _normalize_mcp_name(tool.name)
if self._has_mcp_function_name_conflict("tool", tool.name, local_name):
continue
func = kernel_function(name=local_name, description=tool.description)(partial(self.call_tool, tool.name))
func.__kernel_function_parameters__ = _get_parameter_dicts_from_mcp_tool(tool)
setattr(self, local_name, func)
@abstractmethod
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
"""Get an MCP client."""
pass
async def call_tool(
self, tool_name: str, **kwargs: Any
) -> list[TextContent | ImageContent | BinaryContent | AudioContent | FunctionResultContent | FunctionCallContent]:
"""Call a tool with the given arguments."""
if not self.session:
raise KernelPluginInvalidConfigurationError(
"MCP server not connected, please call connect() before using this method."
)
if not self.load_tools_flag:
raise KernelPluginInvalidConfigurationError(
"Tools are not loaded for this server, please set load_tools=True in the constructor."
)
try:
return _mcp_call_tool_result_to_kernel_contents(await self.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."""
if not self.session:
raise KernelPluginInvalidConfigurationError(
"MCP server not connected, please call connect() before using this method."
)
if not self.load_prompts_flag:
raise KernelPluginInvalidConfigurationError(
"Prompts are not loaded for this server, please set load_prompts=True in the constructor."
)
try:
prompt_result = await self.session.get_prompt(prompt_name, arguments=kwargs)
return [_mcp_prompt_message_to_kernel_content(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
def added_to_kernel(self, kernel: Kernel) -> None:
"""Add the plugin to the kernel."""
self.kernel = kernel
# region: MCP Plugin Implementations
class MCPStdioPlugin(MCPPluginBase):
"""MCP stdio server configuration."""
def __init__(
self,
name: str,
command: str,
*,
load_tools: bool = True,
load_prompts: bool = True,
request_timeout: int | None = None,
session: ClientSession | None = None,
description: str | None = None,
args: list[str] | None = None,
env: dict[str, str] | None = None,
encoding: str | None = None,
kernel: Kernel | None = None,
sampling_consent_callback: SamplingConsentCallback | None = None,
sampling_auto_approve: bool = False,
**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.
load_tools: Whether to load tools from the MCP server.
load_prompts: Whether to load prompts from the MCP server.
request_timeout: The default timeout used for all requests.
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.
kernel: The kernel instance with one or more Chat Completion clients.
sampling_consent_callback: Optional callback for approving MCP sampling requests.
Receives the plugin name and MCP sampling request params. Return
False to deny the request. Takes precedence over sampling_auto_approve.
sampling_auto_approve: Whether to auto-approve MCP sampling requests when no
sampling_consent_callback is configured. Defaults to False (requests are denied).
Set to True only when connecting to a trusted MCP server.
kwargs: Any extra arguments to pass to the stdio client.
"""
super().__init__(
name=name,
description=description,
session=session,
kernel=kernel,
load_tools=load_tools,
load_prompts=load_prompts,
request_timeout=request_timeout,
sampling_consent_callback=sampling_consent_callback,
sampling_auto_approve=sampling_auto_approve,
)
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."""
args: dict[str, Any] = {
"command": self.command,
"args": self.args,
"env": self.env,
}
if self.encoding:
args["encoding"] = self.encoding
if self._client_kwargs:
args.update(self._client_kwargs)
return stdio_client(server=StdioServerParameters(**args))
class MCPSsePlugin(MCPPluginBase):
"""MCP sse server configuration."""
def __init__(
self,
name: str,
url: str,
*,
load_tools: bool = True,
load_prompts: bool = True,
request_timeout: int | None = None,
session: ClientSession | None = None,
description: str | None = None,
headers: dict[str, Any] | None = None,
timeout: float | None = None,
sse_read_timeout: float | None = None,
kernel: Kernel | None = None,
sampling_consent_callback: SamplingConsentCallback | None = None,
sampling_auto_approve: bool = False,
**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.
load_tools: Whether to load tools from the MCP server.
load_prompts: Whether to load prompts from the MCP server.
request_timeout: The default timeout used for all requests.
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.
kernel: The kernel instance with one or more Chat Completion clients.
sampling_consent_callback: Optional callback for approving MCP sampling requests.
Receives the plugin name and MCP sampling request params. Return
False to deny the request. Takes precedence over sampling_auto_approve.
sampling_auto_approve: Whether to auto-approve MCP sampling requests when no
sampling_consent_callback is configured. Defaults to False (requests are denied).
Set to True only when connecting to a trusted MCP server.
kwargs: Any extra arguments to pass to the sse client.
"""
super().__init__(
name=name,
description=description,
session=session,
kernel=kernel,
load_tools=load_tools,
load_prompts=load_prompts,
request_timeout=request_timeout,
sampling_consent_callback=sampling_consent_callback,
sampling_auto_approve=sampling_auto_approve,
)
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."""
args: dict[str, Any] = {
"url": self.url,
}
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:
args["sse_read_timeout"] = self.sse_read_timeout
if self._client_kwargs:
args.update(self._client_kwargs)
return sse_client(**args)
class MCPStreamableHttpPlugin(MCPPluginBase):
"""MCP streamable http server configuration."""
def __init__(
self,
name: str,
url: str,
*,
load_tools: bool = True,
load_prompts: bool = True,
request_timeout: int | None = None,
session: ClientSession | None = None,
description: str | None = None,
headers: dict[str, Any] | None = None,
timeout: float | None = None,
sse_read_timeout: float | None = None,
terminate_on_close: bool | None = None,
kernel: Kernel | None = None,
sampling_consent_callback: SamplingConsentCallback | None = None,
sampling_auto_approve: bool = False,
**kwargs: Any,
) -> None:
"""Initialize the MCP streamable http plugin.
The arguments are used to create a streamable http client.
see mcp.client.streamable_http.streamablehttp_client for more details.
Any extra arguments passed to the constructor will be passed to the
streamable http client constructor.
Args:
name: The name of the plugin.
url: The URL of the MCP server.
load_tools: Whether to load tools from the MCP server.
load_prompts: Whether to load prompts from the MCP server.
request_timeout: The default timeout used for all requests.
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.
terminate_on_close: Close the transport when the MCP client is terminated.
kernel: The kernel instance with one or more Chat Completion clients.
sampling_consent_callback: Optional callback for approving MCP sampling requests.
Receives the plugin name and MCP sampling request params. Return
False to deny the request. Takes precedence over sampling_auto_approve.
sampling_auto_approve: Whether to auto-approve MCP sampling requests when no
sampling_consent_callback is configured. Defaults to False (requests are denied).
Set to True only when connecting to a trusted MCP server.
kwargs: Any extra arguments to pass to the sse client.
"""
super().__init__(
name=name,
description=description,
session=session,
kernel=kernel,
load_tools=load_tools,
load_prompts=load_prompts,
request_timeout=request_timeout,
sampling_consent_callback=sampling_consent_callback,
sampling_auto_approve=sampling_auto_approve,
)
self.url = url
self.headers = headers or {}
self.timeout = timeout
self.sse_read_timeout = sse_read_timeout
self.terminate_on_close = terminate_on_close
self._client_kwargs = kwargs
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
"""Get an MCP streamable http client."""
args: dict[str, Any] = {
"url": self.url,
}
if self.headers:
args["headers"] = self.headers
if self.timeout:
args["timeout"] = self.timeout
if self.sse_read_timeout:
args["sse_read_timeout"] = self.sse_read_timeout
if self.terminate_on_close is not None:
args["terminate_on_close"] = self.terminate_on_close
if self._client_kwargs:
args.update(self._client_kwargs)
return streamablehttp_client(**args)
class MCPWebsocketPlugin(MCPPluginBase):
"""MCP websocket server configuration."""
def __init__(
self,
name: str,
url: str,
*,
load_tools: bool = True,
load_prompts: bool = True,
request_timeout: int | None = None,
session: ClientSession | None = None,
description: str | None = None,
kernel: Kernel | None = None,
sampling_consent_callback: SamplingConsentCallback | None = None,
sampling_auto_approve: bool = False,
**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.
load_tools: Whether to load tools from the MCP server.
load_prompts: Whether to load prompts from the MCP server.
request_timeout: The default timeout used for all requests.
session: The session to use for the MCP connection.
description: The description of the plugin.
kernel: The kernel instance with one or more Chat Completion clients.
sampling_consent_callback: Optional callback for approving MCP sampling requests.
Receives the plugin name and MCP sampling request params. Return
False to deny the request. Takes precedence over sampling_auto_approve.
sampling_auto_approve: Whether to auto-approve MCP sampling requests when no
sampling_consent_callback is configured. Defaults to False (requests are denied).
Set to True only when connecting to a trusted MCP server.
kwargs: Any extra arguments to pass to the websocket client.
"""
super().__init__(
name=name,
description=description,
session=session,
kernel=kernel,
load_tools=load_tools,
load_prompts=load_prompts,
request_timeout=request_timeout,
sampling_consent_callback=sampling_consent_callback,
sampling_auto_approve=sampling_auto_approve,
)
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)
# region: Kernel as MCP Server
@experimental
def create_mcp_server_from_functions(
functions: OneOrMany[KernelFunction | KernelPlugin | object],
*,
prompts: list[PromptTemplateBase] | None = None,
server_name: str = "SK",
version: str | None = None,
instructions: str | None = None,
lifespan: Callable[[Server["LifespanResultT"]], AbstractAsyncContextManager["LifespanResultT"]] | None = None,
plugin_name: str = "mcp",
**kwargs: Any,
) -> Server["LifespanResultT"]:
"""Create an MCP server from a function(s) or plugin(s).
This function automatically creates a MCP server from single or multiple functions or plugins,
all functions are added under the plugin_name that can be set by using the `plugin_name` argument.
It further uses the provided arguments to
configure the server and expose functions as tools, see the mcp documentation for more details.
Args:
functions: The function(s) or plugin(s) instance to use.
This can be a mix of functions, plugins or agents.
Or any object that can be parsed to a plugin.
prompts: The list of prompts to expose as prompts.
server_name: The name of the server.
version: The version of the server.
instructions: The instructions to use for the server.
lifespan: The lifespan of the server.
plugin_name: The name of the plugin to use.
kwargs: Any extra arguments to pass to the server creation.
Returns:
The MCP server instance, it is a instance of
mcp.server.lowlevel.Server
"""
kernel = Kernel()
if not isinstance(functions, list):
functions = [functions]
for func in functions:
if isinstance(func, KernelFunction):
kernel.add_function(plugin_name, func)
else:
try:
kernel.add_plugin(func, plugin_name)
except ValueError as ex:
logger.warning(
"Failed to add plugin %s to kernel: %s",
func.__class__.__name__,
ex,
)
return create_mcp_server_from_kernel(
kernel=kernel,
prompts=prompts,
server_name=server_name,
version=version,
instructions=instructions,
lifespan=lifespan,
**kwargs,
)
@experimental
def create_mcp_server_from_kernel(
kernel: Kernel,
prompts: list[PromptTemplateBase] | None = None,
*,
server_name: str = "SK",
version: str | None = None,
instructions: str | None = None,
lifespan: Callable[[Server["LifespanResultT"]], AbstractAsyncContextManager["LifespanResultT"]] | None = None,
excluded_functions: OptionalOneOrMany[str] = None,
**kwargs: Any,
) -> Server["LifespanResultT"]:
"""Create an MCP server from a kernel instance.
This function automatically creates a MCP server from a kernel instance, it uses the provided arguments to
configure the server and expose functions as tools and prompts, see the mcp documentation for more details.
By default, all functions are exposed as Tools, you can control this by using use the `excluded_functions` argument.
These need to be set to the function name, without the plugin_name.
Args:
kernel: The kernel instance to use.
prompts: The list of prompts to expose as prompts.
server_name: The name of the server.
version: The version of the server.
instructions: The instructions to use for the server.
lifespan: The lifespan of the server.
excluded_functions: The list of function names to exclude from the server.
if None, no functions will be excluded.
kwargs: Any extra arguments to pass to the server creation.
Returns:
The MCP server instance, it is a instance of
mcp.server.lowlevel.Server
"""
server_args: dict[str, Any] = {
"name": server_name,
"version": version,
"instructions": instructions,
}
if lifespan:
server_args["lifespan"] = lifespan
if kwargs:
server_args.update(kwargs)
if excluded_functions is not None and not isinstance(excluded_functions, list):
excluded_functions = [excluded_functions] # type: ignore
server: Server["LifespanResultT"] = Server(**server_args) # type: ignore[call-arg]
functions_to_expose = [
func for func in kernel.get_full_list_of_function_metadata() if func.name not in (excluded_functions or [])
]
exposed_names = frozenset(func.name for func in functions_to_expose)
if len(functions_to_expose) > 0:
@server.list_tools()
async def _list_tools() -> list[types.Tool]:
"""List all tools in the kernel."""
tools = [
types.Tool(
name=func.name,
description=func.description,
inputSchema={
"type": "object",
"properties": {
param.name: param.schema_data
for param in func.parameters
if param.name and param.schema_data and param.include_in_function_choices
},
"required": [
param.name
for param in func.parameters
if param.name and param.is_required and param.include_in_function_choices
],
},
)
for func in functions_to_expose
]
await _log(level="debug", data=f"List of tools: {tools}")
await asyncio.sleep(0.0)
return tools
@server.call_tool()
async def _call_tool(
*args: Any,
) -> Sequence[types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource]:
"""Call a tool in the kernel."""
function_name, arguments = args[0], args[1]
if function_name not in exposed_names:
raise McpError(
error=types.ErrorData(
code=types.METHOD_NOT_FOUND,
message=f"Unknown tool: {function_name}",
)
)
await _log(level="debug", data=f"Calling tool: {function_name}")
result = await _call_kernel_function(function_name, arguments)
if result:
value = result.value
messages: list[
types.TextContent | types.ImageContent | types.AudioContent | types.EmbeddedResource
] = []
if isinstance(value, list):
for item in value:
match item:
case (
TextContent() | ImageContent() | BinaryContent() | AudioContent() | ChatMessageContent()
):
messages.extend(_kernel_content_to_mcp_content_types(item))
case _:
messages.append(
types.TextContent(type="text", text=str(item)),
)
else:
match value:
case TextContent() | ImageContent() | BinaryContent() | AudioContent() | ChatMessageContent():
messages.extend(_kernel_content_to_mcp_content_types(value))
case _:
messages.append(
types.TextContent(type="text", text=str(value)),
)
return messages
raise McpError(
error=types.ErrorData(
code=types.INTERNAL_ERROR,
message=f"Function {function_name} returned no result",
),
)
if prompts:
@server.list_prompts()
async def _list_prompts() -> list[types.Prompt]:
"""List all prompts in the kernel."""
mcp_prompts = []
for prompt in prompts:
mcp_prompts.append(
types.Prompt(
name=prompt.prompt_template_config.name,
description=prompt.prompt_template_config.description,
arguments=[
types.PromptArgument(
name=var.name,
description=var.description,
required=var.is_required,
)
for var in prompt.prompt_template_config.input_variables
],
)
)
await _log(level="debug", data=f"List of prompts: {mcp_prompts}")
return mcp_prompts
@server.get_prompt()
async def _get_prompt(name: str, arguments: dict[str, Any] | None) -> types.GetPromptResult:
"""Get a prompt by name."""
prompt = next((p for p in prompts if p.prompt_template_config.name == name), None)
if prompt is None:
return types.GetPromptResult(description="Prompt not found", messages=[])
# Call the prompt
rendered_prompt = await prompt.render(
kernel,
KernelArguments(**arguments) if arguments is not None else KernelArguments(),
)
# since the return type of a get_prompts is a list of messages,
# we need to convert the rendered prompt to a list of messages
# by using the ChatHistory class
chat_history = ChatHistory.from_rendered_prompt(rendered_prompt)
messages = []
for message in chat_history.messages:
messages.append(
types.PromptMessage(
role=message.role.value
if message.role in (AuthorRole.ASSISTANT, AuthorRole.USER)
else "assistant",
content=_kernel_content_to_mcp_content_types(message)[0],
)
)
return types.GetPromptResult(messages=messages)
async def _log(level: types.LoggingLevel, data: Any) -> None:
"""Log a message to the server and logger."""
# Log to the local logger
logger.log(LOG_LEVEL_MAPPING[level], data)
if server and server.request_context and server.request_context.session:
try:
await server.request_context.session.send_log_message(level=level, data=data)
except Exception as e:
logger.error("Failed to send log message to server: %s", e)
@server.set_logging_level()
async def _set_logging_level(level: types.LoggingLevel) -> None:
"""Set the logging level for the server."""
logger.setLevel(LOG_LEVEL_MAPPING[level])
# emit this log with the new minimum level
await _log(level=level, data=f"Log level set to {level}")
async def _call_kernel_function(function_name: str, arguments: Any) -> FunctionResult | None:
function = kernel.get_function(plugin_name=None, function_name=function_name)
arguments["server"] = server
return await function.invoke(kernel=kernel, **arguments)
return server