MCP 技术分享:从协议握手到 LangGraph 多 Server 调用

0 阅读16分钟

一、为什么现在需要 MCP

AI Agent 落地时,最常见的需求不是“再换一个模型”,而是让模型安全、稳定地访问外部能力。查询数据库、读取文件、调用业务 API、执行计算、搜索知识库,这些操作都需要一个统一的工具边界。

如果没有协议约定,系统很容易演变成下面的局面:

mcp.png

  • 每个模型框架定义一套工具格式。
  • 每个业务系统写一套适配代码。
  • 工具参数、错误、权限、日志和传输方式各不相同。
  • Agent 从一个平台迁移到另一个平台时,大量集成代码需要重写。

Model Context Protocol,简称 MCP,目的就是把这部分集成标准化。它把能力提供方抽象为 Server,把能力使用方抽象为 Client,再用 JSON-RPC 和明确的初始化流程统一工具、资源、提示词、生命周期和传输。

从工程角度看,MCP 的价值不是“让模型更聪明”,而是让工具调用从一堆私有约定,变成可发现、可复用、可测试的协议能力。

这篇文章不会停留在概念介绍,而是以一个可运行的 Python 示例集为主线,完整跑通过:

  1. Server 与 Client 如何握手。
  2. 如何定义和调用工具。
  3. 如何返回结构化数据。
  4. 如何暴露资源和提示词。
  5. 如何管理 Server 生命周期。
  6. 如何接入 LangChain 和 LangGraph。
  7. 如何聚合多个 MCP Server。
  8. 如何在 stdio、SSE 和 Streamable HTTP 之间选择。
  9. 工程化落地时需要关注什么。

配套代码固定使用:

  • Python 3.11 至 3.13
  • mcp==2.2.0
  • LangChain 1.x
  • LangGraph 1.x
  • Pydantic 2.x

需要特别说明的是,MCP Python SDK 2.x 已经将旧版 FastMCP 改名为 MCPServer:

from mcp.server.mcpserver import MCPServer

如果项目仍在 SDK 1.x,常见导入是:

from mcp.server.fastmcp import FastMCP

两者不能混用。本文的代码和测试都以 MCP 2.2.0 为准。

完整示例代码:python-mcp

二、MCP 中的三个角色

MCP 的整体架构设计清晰易懂,只要吃透三个核心角色的定位,就能基本理解整套协议的设计思路与运行逻辑:

Host

Host 是用户真正使用的应用,例如 IDE、聊天客户端、桌面 Agent 或企业工作台。Host 负责模型、会话、用户权限和交互体验。

Client

Client 由 Host 创建,负责连接一个 MCP Server。它管理协议握手、请求发送、响应解析、能力协商和连接关闭。

在 Python 中,核心对象通常是 ClientSession:

from mcp import ClientSession

Server

Server 暴露具体能力。MCP 中最常见的能力有三类:

  • Tools:模型可以调用的函数。
  • Resources:可以读取的上下文数据。
  • Prompts:可复用的提示词模板。

除了这三类能力,Server 还参与协议初始化、能力声明、生命周期管理和传输协商。

如果把 Agent 比作应用层,那么 MCP 解决的是应用层和工具层之间的标准接口问题。

三、最小 Server 与 Client 调用

先看最小 Server:

from mcp.server.mcpserver import MCPServer


server = MCPServer(
    name="server-client-demo",
    title="MCP Server 与 Client 调用",
    description="演示初始化握手、能力协商和安全关闭。",
    instructions="演示初始化握手、能力协商和 ping",
    version="1.0.0",
)


if __name__ == "__main__":
    server.run(transport="stdio")

这里还没有注册工具,但它已经具备一个完整 MCP Server 的基础元数据:

  • name:协议层面的服务唯一标识,用于内部匹配

  • title:面向开发者展示的服务名称,便于直观识别

  • description:简单阐述服务核心能力与用途

  • instructions:面向大模型与客户端的功能说明,辅助能力调用

  • version:服务版本标识,便于后续迭代兼容、版本管控

客户端通过 stdio 启动 Server,然后初始化会话:

async with stdio_client(server_params) as (read_stream, write_stream):
    async with ClientSession(read_stream, write_stream) as session:
        initialized = await session.initialize()

        print(initialized.server_info.name)
        print(initialized.protocol_version)
        print(session.instructions)

        await session.send_ping()

初始化阶段主要完成三件事:

  1. Client 声明自身协议版本和能力。
  2. Server 返回自身信息、协议版本和能力。
  3. 双方在共同支持的范围内建立会话。

这一步看起来简单,但它决定了后续能不能调用工具、读取资源以及使用某些扩展能力。

一个常见错误是把调试日志直接打印到 stdout。对于 stdio 传输,stdout 是 JSON-RPC 数据通道,任何额外输出都可能破坏协议流。调试信息应该写 stderr,这也是示例项目统一遵守的约束。

四、Tools:让模型调用外部能力

Tools 是 MCP 体系中使用频率最高的核心能力,主要用于承载各类可变的业务操作。SDK 提供了简洁的装饰器注册方式,能够自动完成参数校验、工具描述生成。

@server.tool()
def add(a: int, b: int) -> int:
    """返回两个整数之和。"""
    return a + b

函数名、类型标注和 docstring 会被 SDK 转换成工具名称、输入 Schema 和描述。客户端可以先获取工具列表:

result = await session.list_tools()

for tool in result.tools:
    print(tool.name, tool.description)

再调用工具:

result = await session.call_tool("add", {"a": 20, "b": 22})

返回结果中的 content 是协议内容块。对于普通文本结果,可以这样读取:

text = "".join(
    item.text
    for item in result.content
    if getattr(item, "type", None) == "text"
)

异步工具与进度

工具可以是同步函数,也可以是异步函数:

@server.tool()
async def slow_echo(
    text: str,
    ctx: Context,
    delay: float = 0.05,
) -> str:
    await ctx.report_progress(1, 2, "开始")
    await asyncio.sleep(delay)
    await ctx.report_progress(2, 2, "完成")
    return text.upper()

客户端可以传入进度回调:

async def on_progress(
    progress: float,
    total: float | None,
    message: str | None,
) -> None:
    print(progress, total, message)


await session.call_tool(
    "slow_echo",
    {"text": "hello mcp"},
    progress_callback=on_progress,
)

进度通知很适合耗时任务。但要注意:进度更新不是结果本身。真正的业务结果仍然应通过工具返回值表达,进度只用于观测。

预期错误与未预期异常

工具调用过程中的报错大致可分为两类,合理区分、针对性处理,能够提升模型的自主纠错能力,也更便于线上问题快速排查:

第一类是预期业务错误,例如除零、记录不存在、状态不允许:

from mcp.server.mcpserver.exceptions import ToolError


@server.tool()
def divide(a: float, b: float) -> float:
    if b == 0:
        raise ToolError("division by zero")
    return a / b

客户端会收到 is_error=True 的结果,模型可以读取错误内容并决定下一步动作。

第二类是非预期异常,例如数据库断开、代码缺陷或依赖故障。这类异常不应该伪装成业务结果。服务端应记录堆栈,客户端只获得受控的错误信息。

日常开发可以参考这套稳健思路:参数定义清晰规范、返回结构尽量稳定、报错信息可读可追溯。

五、结构化输出:从文本转向可验证对象

传统工具调用经常返回一段 JSON 字符串。模型和程序都能“看到”结果,但调用方还需要自己解析和校验。

MCP 支持结构化输出。服务端可以直接声明 Pydantic 返回模型:

class ReportDetails(BaseModel):
    owner: str
    reviewed: bool
    notes: list[str] = Field(default_factory=list)


class Report(BaseModel):
    title: str
    score: float
    tags: list[str]
    details: ReportDetails


@server.tool(structured_output=True)
def build_report(topic: str, score: float) -> Report:
    return Report(
        title=f"{topic} Report",
        score=score,
        tags=["python", "mcp"],
        details=ReportDetails(
            owner="Ada",
            reviewed=True,
            notes=["schema validated", "nested object"],
        ),
    )

客户端读取:

result = await session.call_tool(
    "build_report",
    {"topic": "Quarterly", "score": 97.5},
)

data = result.structured_content

这里有两个名字需要区分:

小细节:协议原生字段为 structuredContent,Python SDK 2.2.0 已自动适配为蛇形命名 structured_content,无需开发者手动兼容转换。

整体优势十分实用:数据结构可自动校验、嵌套结构完美适配、数据类型规范统一,不用过度依赖模型的文本格式自律,工具调用的稳定性会明显提升。

六、Resources:把上下文作为可寻址资源

Tools 强调“执行动作”,Resources 强调“读取内容”。

静态资源可以绑定固定 URI:

@server.resource(
    "demo://config",
    name="config",
    mime_type="application/json",
)
def config_resource() -> dict[str, object]:
    return {
        "server": "examples",
        "resource_type": "static",
        "features": ["tools", "resources", "prompts"],
    }

动态资源使用 URI 模板:

@server.resource(
    "demo://users/{user_id}",
    name="user_profile",
    mime_type="application/json",
)
def user_profile(user_id: str) -> UserProfile:
    return UserProfile(
        user_id=user_id,
        name="Ada Lovelace",
        role="admin",
    )

客户端可以发现并读取:

resources = await session.list_resources()
templates = await session.list_resource_templates()

config = await session.read_resource("demo://config")
profile = await session.read_resource("demo://users/1")

Resources 适合以下场景:

  • 项目文档和配置。
  • 数据源 Schema。
  • 用户资料。
  • 可版本化的知识片段。
  • 需要被多个工具或提示词复用的上下文。

Resources 和 Tools 不应该互相替代。读取一段配置通常更适合 Resource;执行数据库修改通常更适合 Tool。

七、Prompts:把提示词作为可复用能力

Prompt 不是模型调用的替代品,而是 Server 提供的提示词模板。它可以帮助 Host 统一提示结构、参数和消息序列。

同步提示词:

@server.prompt(
    name="explain_code",
    description="根据编程语言和源码生成代码解释任务。",
)
def explain_code(language: str, code: str) -> list[UserMessage]:
    return [
        UserMessage(
            content=(
                f"请解释下面这段 {language} 代码。\n"
                f"```{language.lower()}\n{code}\n```"
            )
        )
    ]

异步提示词可以访问数据库、文件或其他异步依赖:

@server.prompt()
async def review_code(
    language: str,
    code: str,
    focus: str = "正确性、可读性和边界条件",
) -> list[UserMessage | AssistantMessage]:
    return [
        UserMessage(content=f"请审查 {language} 代码,重点关注 {focus}。"),
        AssistantMessage(content="## 问题清单\n"),
    ]

客户端先获取提示词,再按参数渲染:

prompts = await session.list_prompts()
result = await session.get_prompt(
    "explain_code",
    arguments={
        "language": "Python",
        "code": "def divide(a, b):\n    return a / b",
    },
)

需要注意,Prompt 提供的是消息模板,不是自动执行流程。是否使用、如何组合、是否发送给模型,仍由 Host 决定。

八、Lifespan:管理 Server 生命周期

真实 Server 往往需要初始化连接池、加载模型、读取配置或创建缓存。这些工作不应该放在每次请求里。

MCP Server 支持 lifespan:

@dataclass
class AppState:
    values: list[str] = field(default_factory=list)
    started: bool = False
    closed: bool = False


@asynccontextmanager
async def lifespan(server: MCPServer[AppState]) -> AsyncIterator[AppState]:
    state = AppState(started=True)
    try:
        yield state
    finally:
        state.closed = True

注册到 Server:

server = MCPServer(
    name="lifespan-demo",
    version="1.0.0",
    lifespan=lifespan,
)

工具中通过 Context 访问:

@server.tool()
async def remember(
    value: str,
    ctx: Context[AppState, Any],
) -> dict[str, object]:
    state = ctx.request_context.lifespan_context
    state.values.append(value)
    return {
        "count": len(state.values),
        "values": list(state.values),
    }

同一条连接内,两次调用会得到递增状态。连接关闭后,lifespan 的 finally 负责清理。

比较适合放入生命周期管理: 数据库连接池、HTTP 客户端、全局缓存、模型句柄、服务启动校验、关闭日志记录 不太适合: 单次请求临时变量、用户独立会话、不适合全局共享的可变数据

九、接入 LangChain 与 LangGraph

想要将 MCP 工具接入主流 Agent 框架,需要做一层简易适配转换。这里分享一个实际落地的兼容问题:目前主流的 langchain-mcp-adapters 0.3.1 适配库仅支持 MCP 1.x 版本,暂时无法兼容 2.2.0 新版接口。

因此我们可以手动实现一层轻量桥接工具 MCPToolkit,适配新版 MCP 与 Lang 系列框架的接入逻辑。

class MCPToolkit:
    async def get_tools(self) -> list[StructuredTool]:
        tools: list[StructuredTool] = []
        for server_name, connection in self.connections.items():
            async with self._open(connection) as session:
                result = await session.list_tools()
            tools.extend(
                self._convert(server_name, connection, mcp_tool)
                for mcp_tool in result.tools
            )
        return tools

每个 MCP 工具被转换为 LangChain StructuredTool:

async def call_tool(**arguments: object) -> str:
    async with self._open(connection) as session:
        result = await session.call_tool(mcp_tool.name, arguments)
    if result.is_error:
        raise ToolException(self._result_text(result))
    return self._result_text(result)


return StructuredTool.from_function(
    coroutine=call_tool,
    name=name,
    description=mcp_tool.description or "",
    args_schema=mcp_tool.input_schema,
)

Agent 使用 LangChain 当前推荐入口:

from langchain.agents import create_agent


tools = await MCPToolkit(connections).get_tools()
agent = create_agent(model=model, tools=tools)

result = await agent.ainvoke(
    {"messages": [HumanMessage(content="Use multiply to calculate 6 * 7.")]}
)

开发参考:LangGraph 1.x 已逐步废弃 create_react_agent 接口,建议优先使用全新的 create_agent,可以有效规避版本兼容问题。 示例支持双模式调试:离线 Fake 模型可用于单元测试与 CI 自动化校验,配置 OpenAI 密钥后可切换真实大模型,适配不同开发、测试场景。

默认示例使用 Fake Model,因此离线就能验证工具绑定、工具调用和图执行。设置 OPENAI_API_KEY 后,可以切换到真实 ChatOpenAI:

$env:OPENAI_API_KEY="..."
uv run python examples/07_langchain_tools/client.py --real

这种“双模式”设计很适合工程团队:

  • 离线模式用于单元测试和 CI。
  • 真实模型模式用于端到端验收。
  • 协议连接、工具转换和 Agent 执行可以分层定位问题。

十、多 Server 聚合

在复杂度较高的企业级 Agent 项目中,一般不会采用单一 MCP Server 承载所有能力。我们可以按照业务维度拆分独立服务,比如数据库服务、文件处理服务、业务 API 服务、搜索服务等,各司其职、解耦协作。

这种服务拆分模式具备诸多优势:权限边界更清晰、各服务可独立部署扩容、通用能力可复用、单服务故障影响范围可控,整体架构更稳健。

多 Server 示例同时配置两个 stdio Server:

connections = {
    "math": StdioConnection(
        command=sys.executable,
        args=[str(ROOT / "math_server.py")],
        cwd=str(ROOT),
    ),
    "text": StdioConnection(
        command=sys.executable,
        args=[str(ROOT / "text_server.py")],
        cwd=str(ROOT),
    ),
}

tools = await MCPToolkit(connections).get_tools()
agent = create_agent(model=model, tools=tools)

当多个 Server 暴露同名工具时,需要制定命名策略,例如:

math_add
text_add

多服务开发注意点

多服务聚合场景下,比较容易出现工具重名冲突的问题。建议提前制定统一的命名规范,例如 math_add、text_add,也可以通过命名空间、工具白名单做简单管控,规避冲突问题。

同时还需要关注各类工程细节:服务启动超时处理、单服务故障降级、权限隔离、链路追踪、并发限流、多服务结果聚合等,建议搭配简易路由治理层使用,保障整体服务稳定性。

十一、传输模式:stdio、SSE 与 Streamable HTTP

MCP 支持多种传输方式。选择传输模式时,不能只看“能不能连上”,还要看部署边界、生命周期、认证和可观测性。

stdio

Server 由 Client 作为子进程启动,通过标准输入和标准输出通信:

server.run(transport="stdio")

适合:

  • 本地 CLI。
  • IDE 插件。
  • 桌面 Agent。
  • 单用户工具进程。

优势是部署简单、进程边界明确。限制是难以跨网络复用,服务和客户端生命周期绑定。

SSE

SSE 使用长连接接收服务端事件,再通过 HTTP 发送消息:

server.run(
    transport="sse",
    host="127.0.0.1",
    port=8000,
)

客户端:

async with sse_client("http://127.0.0.1:8000/sse") as streams:
    async with ClientSession(*streams) as session:
        await session.initialize()

适用场景: 老旧项目兼容、需要服务端主动推送消息的业务场景

选型建议: 新项目如需使用 HTTP 类传输,可优先考虑更现代化、适配性更强的 Streamable HTTP

Streamable HTTP

Streamable HTTP 是更现代的 HTTP 传输方式:

server.run(
    transport="streamable-http",
    host="127.0.0.1",
    port=8000,
)

客户端:

async with streamable_http_client("http://127.0.0.1:8000/mcp") as streams:
    async with ClientSession(*streams) as session:
        await session.initialize()

适合:

  • 远程 Server。
  • 多客户端复用。
  • 与现有 HTTP 基础设施集成。
  • 需要网关、负载均衡和统一鉴权的场景。

无论使用哪种传输方式,都建议统一处理:

  • 连接超时。
  • 重试和退避。
  • 会话关闭。
  • 空闲连接回收。
  • 认证和授权。
  • 请求日志和链路追踪。

十二、工程落地参考清单:从 Demo 到生产可用

跑通示例代码仅为入门基础,想要适配生产环境、实现稳定落地,可以参考以下优化方向,逐步完善工程能力:

1. 合理划分能力边界

尽量保持职责清晰、各司其职:Tool 负责执行业务操作、产生数据变更;Resource 负责承载只读上下文、可缓存数据;Prompt 负责通用模板复用,避免各类能力混用混乱。

2. 规范输入输出设计

参数命名语义清晰,用枚举约束固定状态值,复杂业务场景优先使用结构化输出,尽量保证返回值版本兼容,报错信息兼顾可读性、可追溯性与安全性。

3. 分层处理异常机制

可预判的业务异常通过 ToolError 标准化返回,方便模型自主调整调用逻辑;系统级异常完整记录日志用于内部排查,对外统一脱敏展示,规避信息泄露风险。

4. 精细化管理生命周期

服务启动时校验外部依赖可用性,复用连接池等重型资源,服务关闭时主动释放各类句柄,尽量避免在单次请求中重复初始化资源,减少性能损耗。

5. 保障传输与进程安全

stdio 模式杜绝污染通信数据流,HTTP 服务配置合理的鉴权与限流规则,设置合规的资源配额,异常进程留存完整日志,方便线上问题复盘排查。

6. 搭建多层测试体系

可参考四层测试思路稳步落地:单元测试校验参数与数据结构、集成测试验证协议连通性、Agent 测试校验工具调度逻辑、端到端测试覆盖真实业务场景。

7. 完善可观测能力

完善调用日志、耗时统计、异常分层、链路追踪等能力,能够快速定位问题根源,区分故障出在传输层、协议层、服务层还是模型层。。

十三、建议的学习路径

不建议新手直接尝试搭建复杂的企业级 Agent 平台,循序渐进、由浅入深的学习方式,更容易吃透核心知识点:

  1. 跑通最简 Server + Client 案例,理解基础握手与通信逻辑

  2. 熟悉协议协商、心跳连通的底层基础机制

  3. 掌握同步/异步工具、进度上报、上下文调用的使用方式

  4. 尝试用结构化输出替代传统易出错的 JSON 字符串拼接

  5. 理清 Resources、Prompts 两类能力的适用边界与最佳实践

  6. 掌握 Lifespan 机制,实现全局资源的合理管理

  7. 对接 LangChain/LangGraph 框架,实现基础 Agent 调度能力

  8. 实践多 Server 聚合部署,掌握工具冲突的治理方案

  9. 对比三种传输方式的优劣,形成适配业务的选型思路

  10. 最后逐步完善权限管控、限流容错、部署运维等工程能力

十四、结语:聊聊 MCP 的工程价值

MCP 并非颠覆行业的全新技术,更多是为 AI Agent 工程化落地提供一套标准化、可复用的基础设施。

从代码层面来看,MCP 的使用方式十分简洁,仅需少量装饰器与启动代码即可快速搭建服务;但从工程落地层面来看,它统一了协议交互、工具定义、异常规范、生命周期管控、传输策略等零散的开发环节。

它在很大程度上优化了行业通用的集成痛点,弱化了工具与各类 AI 框架的强绑定关系,减少了业务私有接口的重复适配成本。通过标准化的服务拆分与协议调用,能够小幅提升 Agent 项目的复用性、可测试性、可迭代性与可维护性。

简单来说:MCP 让“模型调用工具”这件事,从零散的手动代码适配,变成了一套相对稳定、可落地的标准化工程方案。