跳转至

MCP Client

实现一个极简的 MCP 客户端:以 JSON-RPC 2.0 与服务端通信,支持 initialize、 list_tools、call_tool,并演示工具调用的人工审批(human-in-the-loop)机制。 本示例中客户端直接持有服务端引用(进程内通信);真实场景可替换为 stdio/SSE 传输层。 对应教程:第7卷《MCP》第3章 MCP Client。

文件路径: examples/volume-07-mcp\mcp_client.py


源代码

"""MCP Client 概念实现示例。

实现一个极简的 MCP 客户端:以 JSON-RPC 2.0 与服务端通信,支持 ``initialize``、
``list_tools``、``call_tool``,并演示工具调用的**人工审批(human-in-the-loop)**机制。

本示例中客户端直接持有服务端引用(进程内通信);真实场景可替换为 stdio/SSE 传输层。

对应教程:第7卷《MCP》第3章 MCP Client。
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any

from mcp_server import JSONRPC_VERSION, McpServer

# 审批回调:接收 (tool_name, arguments) 返回 bool
ApprovalCallback = Callable[[str, dict[str, Any]], bool]


@dataclass
class McpClient:
    """MCP 客户端(概念实现)。

    Attributes:
        server: 连接的服务端(进程内引用)。
        require_approval: 是否对工具调用启用人工审批。
        approval_callback: 审批回调,``None`` 时默认拒绝。
    """

    server: McpServer
    require_approval: bool = False
    approval_callback: ApprovalCallback | None = None
    _request_id: int = 0
    _initialized: bool = False
    _call_history: list[dict[str, Any]] = field(default_factory=list)

    def _next_id(self) -> int:
        self._request_id += 1
        return self._request_id

    def _send(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
        """向服务端发送 JSON-RPC 请求并返回响应。"""
        request: dict[str, Any] = {
            "jsonrpc": JSONRPC_VERSION,
            "method": method,
            "id": self._next_id(),
        }
        if params is not None:
            request["params"] = params
        return self.server.handle_request(request)

    def initialize(self) -> dict[str, Any]:
        """初始化连接。

        Returns:
            服务端返回的 serverInfo 与 capabilities。
        """
        resp = self._send("initialize")
        if "error" in resp:
            raise RuntimeError(resp["error"]["message"])
        self._initialized = True
        return resp["result"]

    def list_tools(self) -> list[dict[str, Any]]:
        """列出服务端工具。

        Returns:
            工具信息列表。
        """
        resp = self._send("tools/list")
        if "error" in resp:
            raise RuntimeError(resp["error"]["message"])
        return resp["result"]["tools"]

    def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
        """调用工具,必要时触发审批。

        Args:
            name: 工具名。
            arguments: 参数。

        Returns:
            工具返回内容。

        Raises:
            PermissionError: 审批未通过。
            RuntimeError: 调用失败。
        """
        if self.require_approval:
            approved = self.approval_callback(name, arguments) if self.approval_callback else False
            self._call_history.append({"name": name, "arguments": arguments, "approved": approved})
            if not approved:
                raise PermissionError(f"工具调用 {name} 未获审批")
        else:
            self._call_history.append({"name": name, "arguments": arguments, "approved": True})

        resp = self._send("tools/call", {"name": name, "arguments": arguments})
        if "error" in resp:
            raise RuntimeError(resp["error"]["message"])
        return resp["result"]["content"][0]["text"]

    @property
    def call_history(self) -> list[dict[str, Any]]:
        """工具调用历史记录(便于审计)。"""
        return list(self._call_history)


def connect(server: McpServer, require_approval: bool = False) -> McpClient:
    """便捷工厂:创建客户端并完成初始化。

    Args:
        server: MCP 服务端。
        require_approval: 是否启用审批。

    Returns:
        已初始化的 ``McpClient``。
    """
    client = McpClient(server=server, require_approval=require_approval)
    client.initialize()
    return client


def main() -> None:
    """演示 MCP 客户端调用与审批。"""
    from mcp_server import build_demo_server

    server = build_demo_server()
    client = connect(server)
    print("工具列表:")
    for t in client.list_tools():
        print(f"  - {t['name']}: {t['description']}")

    print("\n调用 add_numbers:")
    print(" ", client.call_tool("add_numbers", {"a": 10, "b": 20}))

    print("\n启用审批(只允许 get_time):")
    approved_client = McpClient(
        server=server,
        require_approval=True,
        approval_callback=lambda name, args: name == "get_time",
    )
    approved_client.initialize()
    print("  get_time:", approved_client.call_tool("get_time", {"timezone": "Asia/Shanghai"}))
    try:
        approved_client.call_tool("add_numbers", {"a": 1, "b": 2})
    except PermissionError as exc:
        print(f"  被拒绝: {exc}")


if __name__ == "__main__":
    main()