Fix MCP tool bug by dropping unset parameters from input (#6125)

Resolves #6096

Additionally: make sure MCP errors are formatted correctly, added unit
tests for mcp servers and upgrade mcp version.
This commit is contained in:
Eric Zhu 2025-03-27 13:22:06 -07:00 committed by GitHub
parent b5ff7ee355
commit 29485ef85b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 117 additions and 19 deletions

View File

@ -135,7 +135,7 @@ semantic-kernel-all = [
rich = ["rich>=13.9.4"]
mcp = [
"mcp>=1.1.3",
"mcp>=1.5.0",
"json-schema-to-pydantic>=0.2.2"
]

View File

@ -603,11 +603,3 @@ class AzureAIChatCompletionClient(ChatCompletionClient):
@property
def capabilities(self) -> ModelInfo:
return self.model_info
def __del__(self) -> None:
# TODO: This is a hack to close the open client
if hasattr(self, "_client"):
try:
asyncio.get_running_loop().create_task(self._client.close())
except RuntimeError:
asyncio.run(self._client.close())

View File

@ -1,8 +1,8 @@
from ._openai_client import (
AZURE_OPENAI_USER_AGENT,
AzureOpenAIChatCompletionClient,
BaseOpenAIChatCompletionClient,
OpenAIChatCompletionClient,
AZURE_OPENAI_USER_AGENT,
)
from .config import (
AzureOpenAIClientConfigurationConfigModel,

View File

@ -8,6 +8,7 @@ import re
import warnings
from asyncio import Task
from dataclasses import dataclass
from importlib.metadata import PackageNotFoundError, version
from typing import (
Any,
AsyncGenerator,
@ -87,8 +88,6 @@ from .config import (
OpenAIClientConfiguration,
OpenAIClientConfigurationConfigModel,
)
from importlib.metadata import PackageNotFoundError, version
logger = logging.getLogger(EVENT_LOGGER_NAME)
trace_logger = logging.getLogger(TRACE_LOGGER_NAME)

View File

@ -1,3 +1,5 @@
import asyncio
import builtins
from abc import ABC
from typing import Any, Generic, Type, TypeVar
@ -54,7 +56,10 @@ class McpToolAdapter(BaseTool[BaseModel, Any], ABC, Generic[TServerParams]):
Raises:
Exception: If the operation is cancelled or the tool execution fails.
"""
kwargs = args.model_dump()
# Convert the input model to a dictionary
# Exclude unset values to avoid sending them to the MCP servers which may cause errors
# for many servers.
kwargs = args.model_dump(exclude_unset=True)
try:
async with create_mcp_server_session(self._server_params) as session:
@ -63,13 +68,16 @@ class McpToolAdapter(BaseTool[BaseModel, Any], ABC, Generic[TServerParams]):
if cancellation_token.is_cancelled():
raise Exception("Operation cancelled")
result = await session.call_tool(self._tool.name, kwargs) # type: ignore
result_future = asyncio.ensure_future(session.call_tool(name=self._tool.name, arguments=kwargs))
cancellation_token.link_future(result_future)
result = await result_future
if result.isError:
raise Exception(f"MCP tool execution failed: {result.content}")
return result.content
except Exception as e:
raise Exception(str(e)) from e
error_message = self._format_errors(e)
raise Exception(error_message) from e
@classmethod
async def from_server_params(cls, server_params: TServerParams, tool_name: str) -> "McpToolAdapter[TServerParams]":
@ -98,3 +106,16 @@ class McpToolAdapter(BaseTool[BaseModel, Any], ABC, Generic[TServerParams]):
)
return cls(server_params=server_params, tool=matching_tool)
def _format_errors(self, error: Exception) -> str:
"""Recursively format errors into a string."""
error_message = ""
if hasattr(builtins, "ExceptionGroup") and isinstance(error, builtins.ExceptionGroup):
# ExceptionGroup is available in Python 3.11+.
# TODO: how to make this compatible with Python 3.10?
for sub_exception in error.exceptions: # type: ignore
error_message += self._format_errors(sub_exception) # type: ignore
else:
error_message += f"{str(error)}\n"
return error_message

View File

@ -1,4 +1,5 @@
import logging
import os
from unittest.mock import AsyncMock, MagicMock
import pytest
@ -8,6 +9,7 @@ from autogen_ext.tools.mcp import (
SseServerParams,
StdioMcpToolAdapter,
StdioServerParams,
mcp_server_tools,
)
from json_schema_to_pydantic import create_model
from mcp import ClientSession, Tool
@ -280,3 +282,85 @@ async def test_sse_adapter_from_server_params(
params_schema["properties"]["test_param"]["type"]
== sample_sse_tool.inputSchema["properties"]["test_param"]["type"]
)
# TODO: why is this test not working in CI?
@pytest.mark.skip(reason="Skipping test_mcp_server_fetch due to CI issues.")
@pytest.mark.asyncio
async def test_mcp_server_fetch() -> None:
params = StdioServerParams(
command="uvx",
args=["mcp-server-fetch"],
read_timeout_seconds=60,
)
tools = await mcp_server_tools(server_params=params)
assert tools is not None
assert tools[0].name == "fetch"
result = await tools[0].run_json({"url": "https://github.com/"}, CancellationToken())
assert result is not None
# TODO: why is this test not working in CI?
@pytest.mark.skip(reason="Skipping due to CI issues.")
@pytest.mark.asyncio
async def test_mcp_server_filesystem() -> None:
params = StdioServerParams(
command="npx",
args=[
"-y",
"@modelcontextprotocol/server-filesystem",
".",
],
read_timeout_seconds=60,
)
tools = await mcp_server_tools(server_params=params)
assert tools is not None
tools = [tool for tool in tools if tool.name == "read_file"]
assert len(tools) == 1
tool = tools[0]
result = await tool.run_json({"path": "README.md"}, CancellationToken())
assert result is not None
# TODO: why is this test not working in CI?
@pytest.mark.skip(reason="Skipping due to CI issues.")
@pytest.mark.asyncio
async def test_mcp_server_git() -> None:
params = StdioServerParams(
command="uvx",
args=["mcp-server-git"],
read_timeout_seconds=60,
)
tools = await mcp_server_tools(server_params=params)
assert tools is not None
tools = [tool for tool in tools if tool.name == "git_log"]
assert len(tools) == 1
tool = tools[0]
repo_path = os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "..")
result = await tool.run_json({"repo_path": repo_path}, CancellationToken())
assert result is not None
@pytest.mark.asyncio
async def test_mcp_server_github() -> None:
# Check if GITHUB_TOKEN is set.
if "GITHUB_TOKEN" not in os.environ:
pytest.skip("GITHUB_TOKEN environment variable is not set. Skipping test.")
params = StdioServerParams(
command="npx",
args=[
"-y",
"@modelcontextprotocol/server-github",
],
env={"GITHUB_PERSONAL_ACCESS_TOKEN": os.environ["GITHUB_TOKEN"]},
read_timeout_seconds=60,
)
tools = await mcp_server_tools(server_params=params)
assert tools is not None
tools = [tool for tool in tools if tool.name == "get_file_contents"]
assert len(tools) == 1
tool = tools[0]
result = await tool.run_json(
{"owner": "microsoft", "repo": "autogen", "path": "python", "branch": "main"}, CancellationToken()
)
assert result is not None

10
python/uv.lock generated
View File

@ -752,7 +752,7 @@ requires-dist = [
{ name = "markitdown", extras = ["all"], marker = "extra == 'file-surfer'", specifier = "~=0.1.0a3" },
{ name = "markitdown", extras = ["all"], marker = "extra == 'magentic-one'", specifier = "~=0.1.0a3" },
{ name = "markitdown", extras = ["all"], marker = "extra == 'web-surfer'", specifier = "~=0.1.0a3" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.1.3" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.5.0" },
{ name = "nbclient", marker = "extra == 'jupyter-executor'", specifier = ">=0.10.2" },
{ name = "ollama", marker = "extra == 'ollama'", specifier = ">=0.4.7" },
{ name = "openai", marker = "extra == 'openai'", specifier = ">=1.66.5" },
@ -4147,19 +4147,21 @@ wheels = [
[[package]]
name = "mcp"
version = "1.1.3"
version = "1.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "uvicorn" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/60/66ebfd280b197f9a9d074c9e46cb1ac3186a32d12e6bd0425c24fe7cf7e8/mcp-1.1.3.tar.gz", hash = "sha256:af11018b8e9153cdd25f3722ec639fe7a462c00213a330fd6f593968341a9883", size = 57903 }
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/b8/08/cfcfa13e41f8d27503c51a8cbf1939d720073ace92469d08655bb5de1b24/mcp-1.1.3-py3-none-any.whl", hash = "sha256:71462d6cd7c06c14689dfcf110ff22286ba1b608cfc3515c0a5cbe33d131731a", size = 36997 },
{ url = "https://files.pythonhosted.org/packages/c1/d1/3ff566ecf322077d861f1a68a1ff025cad337417bd66ad22a7c6f7dfcfaf/mcp-1.5.0-py3-none-any.whl", hash = "sha256:51c3f35ce93cb702f7513c12406bbea9665ef75a08db909200b07da9db641527", size = 73734 },
]
[[package]]