Files
wehub-resource-sync c3bf08ac8d
K8s Workspace Integration Tests / k8s-workspace-tests (push) Has been cancelled
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Python Unittest Coverage / test (macos-15, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (ubuntu-latest, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (windows-latest, 3.11) (push) Has been cancelled
Web UI / check (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:27 +08:00

377 lines
12 KiB
Python

# -*- coding: utf-8 -*-
"""The MCP client test module in agentscope."""
import asyncio
import json
from multiprocessing import Process
from unittest.async_case import IsolatedAsyncioTestCase
from mcp.server import FastMCP
from pydantic import BaseModel
from agentscope.mcp import MCPClient, HttpMCPConfig
from agentscope.message import ToolCallBlock
from agentscope.tool import ToolResponse, ToolChunk, Toolkit
from agentscope.state import AgentState
async def tool_1(arg1: str, arg2: list[int]) -> str:
"""A test tool function.
Args:
arg1 (`str`):
The first argument named arg1.
arg2 (`list[int]`):
The second argument named arg2.
"""
return f"arg1: {arg1}, arg2: {arg2}"
def setup_server() -> None:
"""Set up the streamable HTTP MCP server."""
sse_server = FastMCP("SSE", port=8003)
sse_server.tool(description="A test tool function.")(tool_1)
sse_server.run(transport="sse")
# ---------------------------------------------------------------------------
# Server / tool definitions for $defs preservation test
# ---------------------------------------------------------------------------
class _ItemConfig(BaseModel):
"""Config sub-model to generate $defs in the MCP inputSchema."""
key: str
count: int
async def tool_with_model(name: str, config: _ItemConfig) -> str:
"""A tool whose parameter uses a Pydantic sub-model.
Args:
name: Item name.
config: Item configuration.
"""
return f"name={name}, key={config.key}, count={config.count}"
def setup_defs_server() -> None:
"""Set up an SSE MCP server that exposes a tool with Pydantic
sub-models."""
server = FastMCP("DefsSSE", port=8005)
server.tool()(tool_with_model)
server.run(transport="sse")
class SseMCPClientTest(IsolatedAsyncioTestCase):
"""Test class for MCP server functionality."""
async def asyncTearDown(self) -> None:
"""Tear down the test environment."""
del self.toolkit
while self.process.is_alive():
self.process.terminate()
await asyncio.sleep(5)
async def asyncSetUp(self) -> None:
"""Set up the test environment."""
self.port = 8003
self.process = Process(target=setup_server)
self.process.start()
await asyncio.sleep(10)
self.toolkit = Toolkit()
self.schemas = [
{
"type": "function",
"function": {
"name": "mcp__test_sse_client__tool_1",
"description": "A test tool function.",
"parameters": {
"type": "object",
"properties": {
"arg1": {
"type": "string",
},
"arg2": {
"items": {
"type": "integer",
},
"type": "array",
},
},
"required": [
"arg1",
"arg2",
],
},
},
},
]
async def test_stateless_client(self) -> None:
"""Test the stateless sse MCP client."""
# Create stateless client (is_stateful=False)
stateless_client = MCPClient(
name="test_sse_client",
is_stateful=False,
mcp_config=HttpMCPConfig(
type="http_mcp",
url=f"http://127.0.0.1:{self.port}/sse",
),
)
mcp_tool_1 = await stateless_client.get_tool("tool_1")
# Repeat to ensure idempotency
res_1: ToolChunk = await mcp_tool_1(arg1="123", arg2=[1, 2, 3])
res_2: ToolChunk = await mcp_tool_1(arg1="345", arg2=[4, 5, 6])
res_3: ToolChunk = await mcp_tool_1(arg1="345", arg2=[4, 5, 6])
self.assertEqual(
res_1.content[0].text,
"arg1: 123, arg2: [1, 2, 3]",
)
self.assertEqual(
res_2.content[0].text,
"arg1: 345, arg2: [4, 5, 6]",
)
self.assertEqual(
res_3.content[0].text,
"arg1: 345, arg2: [4, 5, 6]",
)
# Register MCPTool via Toolkit constructor
toolkit_with_mcp = Toolkit(tools=[mcp_tool_1])
schemas = await toolkit_with_mcp.get_tool_schemas()
self.assertListEqual(
schemas,
self.schemas,
)
state = AgentState()
res_gen = toolkit_with_mcp.call_tool(
ToolCallBlock(
id="xx",
type="tool_call",
name="mcp__test_sse_client__tool_1",
input=json.dumps(
{
"arg1": "789",
"arg2": [7, 8, 9],
},
),
),
state=state,
)
final_response = None
async for chunk in res_gen:
if isinstance(chunk, ToolResponse):
final_response = chunk
else:
self.assertIsInstance(chunk, ToolChunk)
self.assertIsNotNone(final_response)
self.assertEqual(
final_response.content[0].text,
"arg1: 789, arg2: [7, 8, 9]",
)
self.toolkit.clear()
self.assertListEqual(self.toolkit.tool_groups, [])
# Try to add the mcp client
self.toolkit = Toolkit(mcps=[stateless_client])
self.assertListEqual(
await self.toolkit.get_tool_schemas(),
self.schemas,
)
self.toolkit.clear()
async def test_stateful_client(self) -> None:
"""Test the stateful sse MCP client."""
# Test stateful client (is_stateful=True)
stateful_client = MCPClient(
name="test_sse_client",
is_stateful=True,
mcp_config=HttpMCPConfig(
type="http_mcp",
url=f"http://127.0.0.1:{self.port}/sse",
),
)
self.assertFalse(stateful_client.is_connected)
await stateful_client.connect()
self.assertTrue(stateful_client.is_connected)
mcp_tool_1 = await stateful_client.get_tool("tool_1")
# Repeat to ensure idempotency
res_1: ToolChunk = await mcp_tool_1(arg1="12", arg2=[1, 2])
res_2: ToolChunk = await mcp_tool_1(arg1="34", arg2=[4, 5])
res_3: ToolChunk = await mcp_tool_1(arg1="34", arg2=[4, 5])
self.assertEqual(
res_1.content[0].text,
"arg1: 12, arg2: [1, 2]",
)
self.assertEqual(
res_2.content[0].text,
"arg1: 34, arg2: [4, 5]",
)
self.assertEqual(
res_3.content[0].text,
"arg1: 34, arg2: [4, 5]",
)
# with toolkit - Register MCPTool via Toolkit constructor
toolkit_with_mcp = Toolkit(tools=[mcp_tool_1])
self.assertListEqual(
await toolkit_with_mcp.get_tool_schemas(),
self.schemas,
)
state = AgentState()
res_gen = toolkit_with_mcp.call_tool(
ToolCallBlock(
id="xx",
type="tool_call",
name="mcp__test_sse_client__tool_1",
input=json.dumps(
{
"arg1": "56",
"arg2": [5, 6],
},
),
),
state=state,
)
final_response = None
async for chunk in res_gen:
if isinstance(chunk, ToolResponse):
final_response = chunk
else:
self.assertIsInstance(chunk, ToolChunk)
self.assertIsNotNone(final_response)
self.assertEqual(
final_response.content[0].text,
"arg1: 56, arg2: [5, 6]",
)
# mcp client level test
self.toolkit.clear()
self.assertListEqual(self.toolkit.tool_groups, [])
self.toolkit = Toolkit(mcps=[stateful_client])
self.assertListEqual(
await self.toolkit.get_tool_schemas(),
self.schemas,
)
await stateful_client.close()
self.assertFalse(stateful_client.is_connected)
class SseSchemaDefsPreservationTest(IsolatedAsyncioTestCase):
"""End-to-end tests for $defs preservation in MCP tool schemas.
These tests start a real FastMCP server that exposes a tool whose
parameter is a Pydantic sub-model. FastMCP generates an inputSchema with
``$defs`` for the sub-model. We verify that the schema returned by
``await toolkit.get_tool_schemas()`` preserves those ``$defs`` and that
Pydantic-generated ``title`` fields inside ``$defs`` are stripped.
"""
async def asyncSetUp(self) -> None:
"""Start the $defs test server."""
self.port = 8005
self.process = Process(target=setup_defs_server)
self.process.start()
await asyncio.sleep(10)
self.schemas = [
{
"type": "function",
"function": {
"name": "mcp__test_defs_client__tool_with_model",
"description": "A tool whose parameter uses a "
"Pydantic sub-model.\n\n Args:\n "
"name: Item name.\n "
"config: Item configuration.\n ",
"parameters": {
"$defs": {
"_ItemConfig": {
"description": "Config sub-model to "
"generate $defs in the "
"MCP inputSchema.",
"properties": {
"key": {"type": "string"},
"count": {"type": "integer"},
},
"required": ["key", "count"],
"type": "object",
},
},
"properties": {
"name": {"type": "string"},
"config": {"$ref": "#/$defs/_ItemConfig"},
},
"required": ["name", "config"],
"type": "object",
},
},
},
]
async def asyncTearDown(self) -> None:
"""Stop the $defs test server."""
while self.process.is_alive():
self.process.terminate()
await asyncio.sleep(5)
async def test_defs_preserved_and_titles_stripped(self) -> None:
"""$defs from Pydantic sub-model parameters must survive the full
pipeline.
Failure scenario (before fix):
MCPTool.__init__ only copied ``properties`` and ``required``,
so ``$defs._ItemConfig`` was silently dropped. The LLM would
receive a schema where ``config`` had an unresolvable
``$ref: "#/$defs/_ItemConfig"``.
Expected behaviour (after fix):
- ``MCPTool.input_schema`` contains ``$defs._ItemConfig``
- ``await toolkit.get_tool_schemas()`` output contains ``$defs``
with the ref resolved and Pydantic titles stripped.
"""
client = MCPClient(
name="test_defs_client",
is_stateful=False,
mcp_config=HttpMCPConfig(
type="http_mcp",
url=f"http://127.0.0.1:{self.port}/sse",
),
)
mcp_tool = await client.get_tool("tool_with_model")
# 1. input_schema must preserve $defs
self.assertIn(
"$defs",
mcp_tool.input_schema,
"MCPTool.input_schema must preserve $defs from inputSchema",
)
# 2. get_tool_schemas() must preserve $defs and strip titles
toolkit = Toolkit(tools=[mcp_tool])
schemas = await toolkit.get_tool_schemas()
self.assertListEqual(schemas, self.schemas)