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
377 lines
12 KiB
Python
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)
|