chore: 添加虚拟环境到仓库

- 添加 backend_service/venv 虚拟环境
- 包含所有Python依赖包
- 注意:虚拟环境约393MB,包含12655个文件
This commit is contained in:
2025-12-03 10:19:25 +08:00
parent a6c2027caa
commit c4f851d387
12655 changed files with 3009376 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
"""The MCP module in AgentScope, that provides fine-grained control over
the MCP servers."""
from ._client_base import MCPClientBase
from ._mcp_function import MCPToolFunction
from ._stateful_client_base import StatefulClientBase
from ._stdio_stateful_client import StdIOStatefulClient
from ._http_stateless_client import HttpStatelessClient
from ._http_stateful_client import HttpStatefulClient
__all__ = [
"MCPToolFunction",
"MCPClientBase",
"StatefulClientBase",
"StdIOStatefulClient",
"HttpStatelessClient",
"HttpStatefulClient",
]

View File

@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
"""The base class for MCP clients in AgentScope."""
from abc import abstractmethod
from typing import Callable, List
import mcp.types
from .._logging import logger
from ..message import ImageBlock, Base64Source, AudioBlock, TextBlock
class MCPClientBase:
"""Base class for MCP clients."""
def __init__(self, name: str) -> None:
"""Initialize the MCP client with a name.
Args:
name (`str`):
The name to identify the MCP server, which should be unique
across the MCP servers.
"""
self.name = name
@abstractmethod
async def get_callable_function(
self,
func_name: str,
wrap_tool_result: bool = True,
) -> Callable:
"""Get a tool function by its name."""
@staticmethod
def _convert_mcp_content_to_as_blocks(
mcp_content_blocks: list,
) -> List[TextBlock | ImageBlock | AudioBlock]:
"""Convert MCP content to AgentScope blocks."""
as_content: list = []
for content in mcp_content_blocks:
if isinstance(content, mcp.types.TextContent):
as_content.append(
TextBlock(
type="text",
text=content.text,
),
)
elif isinstance(content, mcp.types.ImageContent):
as_content.append(
ImageBlock(
type="image",
source=Base64Source(
type="base64",
media_type=content.mimeType,
data=content.data,
),
),
)
elif isinstance(content, mcp.types.AudioContent):
as_content.append(
AudioBlock(
type="audio",
source=Base64Source(
type="base64",
media_type=content.mimeType,
data=content.data,
),
),
)
elif isinstance(content, mcp.types.EmbeddedResource):
if isinstance(
content.resource,
mcp.types.TextResourceContents,
):
as_content.append(
TextBlock(
type="text",
text=content.resource.model_dump_json(indent=2),
),
)
else:
# TODO: support the BlobResourceContents in the future,
# which is a base64-encoded string representing the
# binary data
logger.error(
"Unsupported EmbeddedResource content type: %s. "
"Skipping this content.",
type(content.resource),
)
else:
logger.warning(
"Unsupported content type: %s. Skipping this content.",
type(content),
)
return as_content

View File

@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
"""The MCP stateful HTTP client module in AgentScope."""
from typing import Any, Literal
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamablehttp_client
from ._stateful_client_base import StatefulClientBase
class HttpStatefulClient(StatefulClientBase):
"""The stateful sse/streamable HTTP MCP client implementation in
AgentScope.
.. tip:: The stateful client is recommended for MCP servers that need to
maintain session states, e.g. web browsers or other interactive
MCP servers.
.. note:: The stateful client will maintain one session across multiple
tool calls, until the client is closed by explicitly calling the
`close()` method.
.. note:: When multiple HttpStatefulClient instances are connected,
they should be closed following the Last In First Out (LIFO) principle
to avoid potential errors. Always close the most recently registered
client first, then work backwards to the first one.
For more details, please refer to this `issue
<https://github.com/modelcontextprotocol/python-sdk/issues/577>`_.
"""
def __init__(
self,
name: str,
transport: Literal["streamable_http", "sse"],
url: str,
headers: dict[str, str] | None = None,
timeout: float = 30,
sse_read_timeout: float = 60 * 5,
**client_kwargs: Any,
) -> None:
"""Initialize the streamable HTTP MCP client.
Args:
name (`str`):
The name to identify the MCP server, which should be unique
across the MCP servers.
transport (`Literal["streamable_http", "sse"]`):
The transport type of MCP server. Generally, the URL of sse
transport should end with `/sse`, while the streamable HTTP
URL ends with `/mcp`.
url (`str`):
The URL to the MCP server.
headers (`dict[str, str] | None`, optional):
Additional headers to include in the HTTP request.
timeout (`float`, optional):
The timeout for the HTTP request in seconds. Defaults to 30.
sse_read_timeout (`float`, optional):
The timeout for reading Server-Sent Events (SSE) in seconds.
Defaults to 300 (5 minutes).
**client_kwargs (`Any`):
The additional keyword arguments to pass to the streamable
HTTP client.
"""
super().__init__(name=name)
assert transport in ["streamable_http", "sse"]
self.transport = transport
if self.transport == "streamable_http":
self.client = streamablehttp_client(
url=url,
headers=headers,
timeout=timeout,
sse_read_timeout=sse_read_timeout,
**client_kwargs,
)
else:
self.client = sse_client(
url=url,
headers=headers,
timeout=timeout,
sse_read_timeout=sse_read_timeout,
**client_kwargs,
)

View File

@@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-
"""The MCP streamable HTTP server."""
from contextlib import _AsyncGeneratorContextManager
from typing import Any, Callable, Awaitable, Literal, List
import mcp.types
from mcp import ClientSession
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamablehttp_client
from . import MCPToolFunction
from ._client_base import MCPClientBase
from ..tool import ToolResponse
class HttpStatelessClient(MCPClientBase):
"""The sse/streamable HTTP MCP client implementation in AgentScope.
.. note:: Note this client is stateless, meaning it won't maintain the
session state across multiple tool calls. Each tool call will start a
new session and close it after the call is done.
"""
stateful: bool = False
"""Whether the MCP server is stateful, meaning it will maintain the
session state across multiple tool calls, or stateless, meaning it
will start a new session for each tool call."""
def __init__(
self,
name: str,
transport: Literal["streamable_http", "sse"],
url: str,
headers: dict[str, str] | None = None,
timeout: float = 30,
sse_read_timeout: float = 60 * 5,
**client_kwargs: Any,
) -> None:
"""Initialize the streamable HTTP MCP server.
Args:
name (`str`):
The name to identify the MCP server, which should be unique
across the MCP servers.
transport (`Literal["streamable_http", "sse"]`):
The transport type of MCP server. Generally, the URL of sse
transport should end with `/sse`, while the streamable HTTP
URL ends with `/mcp`.
url (`str`):
The URL of the MCP server.
headers (`dict[str, str] | None`, optional):
Additional headers to include in the HTTP request.
timeout (`float`, optional):
The timeout for the HTTP request in seconds. Defaults to 30.
sse_read_timeout (`float`, optional):
The timeout for reading Server-Sent Events (SSE) in seconds.
Defaults to 300 (5 minutes).
**client_kwargs (`Any`):
The additional keyword arguments to pass to the streamable
HTTP client.
"""
super().__init__(name=name)
assert transport in ["streamable_http", "sse"]
self.transport = transport
self.client_config = {
"url": url,
"headers": headers or {},
"timeout": timeout,
"sse_read_timeout": sse_read_timeout,
**client_kwargs,
}
self._tools = None
def get_client(self) -> _AsyncGeneratorContextManager[Any]:
"""The disposable MCP client object, which is a context manager."""
if self.transport == "sse":
return sse_client(**self.client_config)
if self.transport == "streamable_http":
return streamablehttp_client(**self.client_config)
raise ValueError(
f"Unsupported transport type: {self.transport}. "
"Supported types are 'sse' and 'streamable_http'.",
)
async def get_callable_function(
self,
func_name: str,
wrap_tool_result: bool = True,
) -> Callable[..., Awaitable[mcp.types.CallToolResult | ToolResponse]]:
"""Get a tool function by its name.
Args:
func_name (`str`):
The name of the tool function.
wrap_tool_result (`bool`, defaults to `True`):
Whether to wrap the tool result into agentscope's
`ToolResponse` object. If `False`, the raw result type
`mcp.types.CallToolResult` will be returned.
Returns:
`Callable[..., Awaitable[mcp.types.CallToolResult | \
ToolResponse]]`:
An async tool function that returns either
`mcp.types.CallToolResult` or `ToolResponse` when called.
"""
if self._tools is None:
await self.list_tools()
target_tool = None
for tool in self._tools:
if tool.name == func_name:
target_tool = tool
break
if target_tool is None:
raise ValueError(
f"Tool '{func_name}' not found in the MCP server ",
)
return MCPToolFunction(
mcp_name=self.name,
tool=target_tool,
wrap_tool_result=wrap_tool_result,
client_gen=self.get_client,
)
async def list_tools(self) -> List[mcp.types.Tool]:
"""List all tools available on the MCP server.
Returns:
`mcp.types.ListToolsResult`:
The result containing the list of tools.
"""
async with self.get_client() as cli:
read_stream, write_stream = cli[0], cli[1]
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
res = await session.list_tools()
self._tools = res.tools
return res.tools

View File

@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
"""The MCP tool function class in AgentScope."""
from contextlib import _AsyncGeneratorContextManager
from typing import Any, Callable
import mcp
from mcp import ClientSession
from ._client_base import MCPClientBase
from .._utils._common import _extract_json_schema_from_mcp_tool
from ..tool import ToolResponse
class MCPToolFunction:
"""An MCP tool function class that can be called directly."""
name: str
"""The name of the tool function."""
description: str
"""The description of the tool function."""
json_schema: dict[str, Any]
"""JSON schema of the tool function"""
def __init__(
self,
mcp_name: str,
tool: mcp.types.Tool,
wrap_tool_result: bool,
client_gen: Callable[..., _AsyncGeneratorContextManager[Any]]
| None = None,
session: ClientSession | None = None,
) -> None:
"""Initialize the MCP function."""
self.mcp_name = mcp_name
self.name = tool.name
self.description = tool.description
self.json_schema = _extract_json_schema_from_mcp_tool(tool)
self.wrap_tool_result = wrap_tool_result
# Cannot be None at the same time
if (
client_gen is None
and session is None
or (client_gen is not None and session is not None)
):
raise ValueError(
"Either client or session must be provided, but not both.",
)
self.client_gen = client_gen
self.session = session
async def __call__(
self,
**kwargs: Any,
) -> mcp.types.CallToolResult | ToolResponse:
"""Call the MCP tool function with the given arguments, and return
the result."""
if self.client_gen:
async with self.client_gen() as cli:
read_stream, write_stream = cli[0], cli[1]
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
res = await session.call_tool(
self.name,
arguments=kwargs,
)
else:
res = await self.session.call_tool(
self.name,
arguments=kwargs,
)
if self.wrap_tool_result:
as_content = MCPClientBase._convert_mcp_content_to_as_blocks(
res.content,
)
return ToolResponse(
content=as_content,
metadata=res.meta,
)
return res

View File

@@ -0,0 +1,166 @@
# -*- coding: utf-8 -*-
"""The base MCP stateful client class in AgentScope, that provides basic
functionality for stateful MCP clients."""
from abc import ABC
from contextlib import AsyncExitStack
from typing import List
import mcp
from mcp import ClientSession
from ._client_base import MCPClientBase
from ._mcp_function import MCPToolFunction
from .._logging import logger
class StatefulClientBase(MCPClientBase, ABC):
"""The base class for stateful MCP clients in AgentScope, which maintains
the session state across multiple tool calls.
The developers should use `connect()` and `close()` methods to manage
the client lifecycle.
"""
is_connected: bool
"""If connected to the MCP server"""
def __init__(self, name: str) -> None:
"""Initialize the stateful MCP client.
Args:
name (`str`):
The name to identify the MCP server, which should be unique
across the MCP servers.
"""
super().__init__(name=name)
self.client = None
self.stack = None
self.session = None
self.is_connected = False
# Cache the tools to avoid fetching them multiple times
self._cached_tools = None
async def connect(self) -> None:
"""Connect to MCP server."""
if self.is_connected:
raise RuntimeError(
"The MCP server is already connected. Call close() "
"before connecting again.",
)
self.stack = AsyncExitStack()
try:
context = await self.stack.enter_async_context(
self.client,
)
read_stream, write_stream = context[0], context[1]
self.session = ClientSession(read_stream, write_stream)
await self.stack.enter_async_context(self.session)
await self.session.initialize()
self.is_connected = True
logger.info("MCP client connected.")
except Exception:
await self.stack.aclose()
self.stack = None
raise
async def close(self) -> None:
"""Clean up the MCP client resources. You must call this method when
your application is done."""
if not self.is_connected:
raise RuntimeError(
"The MCP server is not connected. Call connect() before "
"closing.",
)
try:
await self.stack.aclose()
logger.info("MCP client closed.")
except Exception as e:
logger.warning("Error during MCP client cleanup: %s", e)
finally:
self.stack = None
self.session = None
self.is_connected = False
async def list_tools(self) -> List[mcp.types.Tool]:
"""Get all available tools from the server.
Returns:
`mcp.types.ListToolsResult`:
A list of available MCP tools.
"""
self._validate_connection()
res = await self.session.list_tools()
# Cache the tools for later use
self._cached_tools = res.tools
return res.tools
async def get_callable_function(
self,
func_name: str,
wrap_tool_result: bool = True,
) -> MCPToolFunction:
"""Get an async tool function from the MCP server by its name, so
that you can call it directly, wrap it into your own function, or
anyway you like.
.. note:: Currently, only the text, image, and audio results are
supported in this function.
Args:
func_name (`str`):
The name of the tool function to get.
wrap_tool_result (`bool`):
Whether to wrap the tool result into agentscope's
`ToolResponse` object. If `False`, the raw result type
`mcp.types.CallToolResult` will be returned.
Returns:
`MCPToolFunction`:
A callable async function that returns either
`mcp.types.CallToolResult` or `ToolResponse` when called.
"""
self._validate_connection()
if self._cached_tools is None:
await self.list_tools()
target_tool = None
for tool in self._cached_tools:
if tool.name == func_name:
target_tool = tool
break
if target_tool is None:
raise ValueError(
f"Tool '{func_name}' not found in the MCP server",
)
return MCPToolFunction(
mcp_name=self.name,
tool=target_tool,
wrap_tool_result=wrap_tool_result,
session=self.session,
)
def _validate_connection(self) -> None:
"""Validate the connection to the MCP server."""
if not self.is_connected:
raise RuntimeError(
"The connection is not established. Call connect() "
"before using the client.",
)
if not self.session:
raise RuntimeError(
"The session is not initialized. Call connect() "
"before using the client.",
)

View File

@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""The StdIO MCP server implementation in AgentScope, which provides
function-level fine-grained control over the MCP servers using standard IO."""
from typing import Literal
from mcp import stdio_client, StdioServerParameters
from ._stateful_client_base import StatefulClientBase
class StdIOStatefulClient(StatefulClientBase):
"""A client class that sets up and manage StdIO MCP server connections, and
provides function-level fine-grained control over the MCP servers.
.. tip:: The stateful client is recommended for MCP servers that need to
maintain session states, e.g. web browsers or other interactive
MCP servers.
.. note:: The stateful client will maintain one session across multiple
tool calls, until the client is closed by explicitly calling the
`close()` method.
.. note:: When multiple StdIOStatefulClient instances are connected,
they should be closed following the Last In First Out (LIFO) principle
to avoid potential errors. Always close the most recently registered
client first, then work backwards to the first one.
For more details, please refer to this `issue
<https://github.com/modelcontextprotocol/python-sdk/issues/577>`_.
"""
def __init__(
self,
name: str,
command: str,
args: list[str] | None = None,
env: dict[str, str] | None = None,
cwd: str | None = None,
encoding: str = "utf-8",
encoding_error_handler: Literal[
"strict",
"ignore",
"replace",
] = "strict",
) -> None:
"""Initialize the MCP server with std IO.
Args:
name (`str`):
The name to identify the MCP server, which should be unique
across the MCP servers.
command (`str`):
The executable to run to start the server.
args (`list[str] | None`, optional):
Command line arguments to pass to the executable.
env (`dict[str, str] | None`, optional):
The environment to use when spawning the process.
cwd (`str | None`, optional):
The working directory to use when spawning the process.
encoding (`str`, optional):
The text encoding used when sending/receiving messages to the
server. Defaults to "utf-8".
encoding_error_handler (`Literal["strict", "ignore", "replace"]`, \
defaults to "strict"):
The text encoding error handler.
"""
super().__init__(name=name)
self.client = stdio_client(
StdioServerParameters(
command=command,
args=args or [],
env=env,
cwd=cwd,
encoding=encoding,
encoding_error_handler=encoding_error_handler,
),
)