LangChain 高级用法之 MCP

LangChain 1.0 于 2025 年 10 月 22 日发布,这是一个里程碑式的版本,听说在 0.x 要创建一个 agent 很麻烦, 那时候内部是真正的 , 1.0 后虽然还叫 LangChain, 实际上内部实现是图(LangGraph), 用 create_agent() 创建 agent. 从数据结构来看,链表 更能直观的表达 Agent 与模型及工具的交互场景。很庆幸在 LangChain 1.0 之后才开始学习这个框架,不用体验 LangChain 0.x 的痛苦。

大概对 LangChain 的 tools 有些许了解之后,现在跳到 Model Context Protocol(MCP) 协议这一章,本人对 MCP 的初步理解是相对于工具, MCP 是一个远程(跨进程)的工具。为了方便的使用互联网上的各种资源,MCP 在实现一个完备的 Agent 也是一个非常重要的工具。

Model Context Protocol (MCP)Anthropic 推出并开放的协议,用于构建 Agent 与外部资源的交互,下面会与工具对照着学习它. 以前也写过一篇关于 MCP 的文章,今天从不同的角度再次强化对 MCP 的理解。

LangChain 中要使用 MCP 需安装 langchain-mcp-adapters 依赖,然后使用它的 MultiServerMCPClient, 它是无状态的。要创建自己的 MCP 服务,使用 FastMCP 库。

1uv add langchain-mcp-adapters
2uv add fastmcp   # develop MCP server

体验 LangChainMCP

先来体验一下 MCP 的能力,创建三个 Python 代码文件,分别是

  1. math_server.py: 通过 stdio(标准输入输出) 使用的 MCP 服务
  2. weather_server.py: 通过 http 使用的 MCP 服务
  3. mcp_client_agent.py: 使用以上两个 MCP 服务的 Agent, 它也是一个 MCP 客户端

math_server.py

 1import sys
 2
 3from fastmcp import FastMCP
 4
 5mcp = FastMCP("Math")
 6
 7@mcp.tool()
 8def add(a: int, b: int) -> int:
 9    """Add two numbers"""
10    print(f"math:add called with {a=} and {b=}", file=sys.stderr)
11    return a + b
12
13@mcp.tool()
14def multiply(a: int, b: int) -> int:
15    """Multiply two numbers"""
16    print(f"math:multiply called with {a=} and {b=}", file=sys.stderr)
17    return a * b
18
19if __name__ == "__main__":
20    mcp.run(transport="stdio", show_banner=False)

weather_server.py

 1from fastmcp import FastMCP
 2
 3mcp = FastMCP("Weather")
 4
 5@mcp.tool()
 6async def get_weather(location: str) -> str:
 7    """Get weather for location."""
 8    print(f"Weather:get_weather called with {location=}")
 9    return "It's always sunny in New York"
10
11if __name__ == "__main__":
12    mcp.run(transport="streamable-http", show_banner=False)

mcp_client_agent.py

 1import asyncio
 2from langchain_mcp_adapters.client import MultiServerMCPClient
 3from langchain.agents import create_agent
 4from pathlib import Path
 5
 6
 7async def main():
 8    math_server_path = str((Path(__file__).resolve().parent / "math_server.py"))
 9
10    client = MultiServerMCPClient(
11        {
12            "math": {
13                "transport": "stdio",  # Local subprocess communication
14                "command": "python",
15                # Absolute path to your math_server.py file
16                "args": [math_server_path],
17            },
18            "weather": {
19                "transport": "http",  # HTTP-based remote server
20                # Ensure you start your weather server on port 8000
21                "url": "http://localhost:8000/mcp",
22            }
23        }
24    )
25
26    tools = await client.get_tools()
27    agent = create_agent(
28        "ollama:gemma4:e4b",
29        tools=tools,
30    )
31    math_response = await agent.ainvoke(
32        {"messages": [{"role": "user", "content": "what's (3 + 5) x 12?"}]}
33    )
34
35    for message in math_response["messages"]:
36        message.pretty_print()
37
38    weather_response = await agent.ainvoke(
39        {"messages": [{"role": "user", "content": "what is the weather in nyc?"}]}
40    )
41
42    for message in weather_response["messages"]:
43        message.pretty_print()
44
45if __name__ == "__main__":
46    asyncio.run(main())

先要启动 http 模式的 weather_server.py

1uv run python src/langchain_study/mcp/weather_server.py
2[04/22/26 16:02:46] INFO     Starting MCP server 'Weather' with transport 'streamable-http' on http://127.0.0.1:8000/mcp                                                                                                            transport.py:301
3INFO:     Started server process [99625]
4INFO:     Waiting for application startup.
5INFO:     Application startup complete.
6INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

然后执行 mcp_client_agent.py

 1uv run python src/langchain_study/mcp/mcp_client_agent.py 
 2[04/22/26 16:06:20] INFO     Starting MCP server 'Math' with transport 'stdio'                                                                                                                                                      transport.py:209
 3[04/22/26 16:06:39] INFO     Starting MCP server 'Math' with transport 'stdio'                                                                                                                                                      transport.py:209
 4[04/22/26 16:06:39] INFO     Starting MCP server 'Math' with transport 'stdio'                                                                                                                                                      transport.py:209
 5math:multiply called with a=8 and b=12
 6math:add called with a=3 and b=5
 7================================ Human Message =================================
 8
 9what's (3 + 5) x 12?
10================================== Ai Message ==================================
11Tool Calls:
12  add (497dc421-b912-4664-82a4-e7ca8cfbc5e2)
13 Call ID: 497dc421-b912-4664-82a4-e7ca8cfbc5e2
14  Args:
15    a: 3
16    b: 5
17================================= Tool Message =================================
18Name: add
19
20[{'type': 'text', 'text': '8', 'id': 'lc_d3ef66f9-d7ba-4aeb-86fb-2548bd43d9bf'}]
21================================== Ai Message ==================================
22Tool Calls:
23  multiply (f242adcd-d1a1-4687-be75-a0a307ca32fe)
24 Call ID: f242adcd-d1a1-4687-be75-a0a307ca32fe
25  Args:
26    a: 8
27    b: 12
28================================= Tool Message =================================
29Name: multiply
30
31[{'type': 'text', 'text': '96', 'id': 'lc_dc22f392-4fb2-45a4-a57a-e00a5de2d960'}]
32================================== Ai Message ==================================
33
3496
35================================ Human Message =================================
36
37what is the weather in nyc?
38================================== Ai Message ==================================
39Tool Calls:
40  get_weather (cc8dd3bd-cb54-4856-808a-1f3979fda25c)
41 Call ID: cc8dd3bd-cb54-4856-808a-1f3979fda25c
42  Args:
43    location: nyc
44================================= Tool Message =================================
45Name: get_weather
46
47[{'type': 'text', 'text': "It's always sunny in New York", 'id': 'lc_aa46f91e-37a9-48bf-8959-558d233a44fe'}]
48================================== Ai Message ==================================
49Tool Calls:
50  get_weather (6d8a8492-22eb-4a72-b8f2-3f82194276e6)
51 Call ID: 6d8a8492-22eb-4a72-b8f2-3f82194276e6
52  Args:
53    location: nyc
54================================= Tool Message =================================
55Name: get_weather
56
57[{'type': 'text', 'text': "It's always sunny in New York", 'id': 'lc_07da67af-1ce3-474e-9199-8b7be3a6769e'}]
58================================== Ai Message ==================================

从这个输出对话,可见它与使用普通工具是完全一样的交互过程, 也是下面几个过程

  • HumanMessage: 告诉模型有哪个工具可调用
  • AIMessage: 告诉客户端应如何调用工具,工具名及参数列表
  • ToolMessage: 客户端调用工具,把结果用 ToolMessage 回送给模型
  • AIMessage: 模型根据工具调用的结果继续对话

只不过用 MCP 时,工具调用是跨进程,由启动的子进程通过 stdio 交互,或与远程的 HTTP 交互,背后的交互协议是 JSON-RPC 2.0.

math_server 中看到 print() 到标准错误输出的内容,以及 http 时看到 weather_server 中的控制台输出。

math:multiply called with a=8 and b=12
math:add called with a=3 and b=5
Weather:get_weather called with location='nyc'

langchain_mcp_adapter 统一用 MultiServerMCPClient, 而在 mcp 包中有不同形态单独的客户端,如下

1from mcp.client.stdio import stdio_client
2from mcp.client.sse import sse_client
3from mcp.client.websocket import websocket_client
4from mcp.client.streamable_http import streamable_http_client

剖析上面的 MCP

math_serverweather_server 中的实现与启动差不多,mcp.run() 时,transport 参数的选择有 http, stdio, streamable-http, sse(Sever Send Event). 对于 factmcp, httpstreamable-http 是一样的的。 本例用了 stdiostreamable-http 两种方式。 特别要注意的是,对于 stdio 交互方式是通过标准的输入与输出,如果在 stdio@mcp.tool() 装饰的工具函数中用了往标准输出打印的信息, 如 print("hello") 将把把该输出作为工具调用的返回而造成异常。

stdio 模型的 MCP 要通过 commandargs 来告诉 MultiServerMCPClient 如何启动该 MCP 子进程,启动在 MCP 客户端与服务端之间通过标准输入与输出交互,所以不支持并发。试想多个线程或进程住同一进程的标准输入发送内容,将会造成混乱。

MulltiServerMCPClient 是异步

由于 MultiServerMCPClient 是异步,因此,启动源头就要用 asyncio.run(main()) 来执行,外层是 async, 可以调用 agent 的相应带 a 前缀的异步方法 await agent.ainvoke().

收集工具函数

什么说本质上 MCP 也是工具调用,在 agent = create_agent() 行打个断点查看 tools = await client.get_tools() 的内容为

1[
2    StructuredTool(name='add', description='Add two numbers', args_schema={'additionalProperties': False, 'properties': {'a': {'type': 'integer'}, 'b': {'type': 'integer'}}, 'required': ['a', 'b'], 'type': 'object'}, metadata={'_meta': {'fastmcp': {'tags': []}}}, response_format='content_and_artifact', coroutine=<function convert_mcp_tool_to_langchain_tool.<locals>.call_tool at 0x10c35b060>),
3    StructuredTool(name='multiply', description='Multiply two numbers', args_schema={'additionalProperties': False, 'properties': {'a': {'type': 'integer'}, 'b': {'type': 'integer'}}, 'required': ['a', 'b'], 'type': 'object'}, metadata={'_meta': {'fastmcp': {'tags': []}}}, response_format='content_and_artifact', coroutine=<function convert_mcp_tool_to_langchain_tool.<locals>.call_tool at 0x10c35a520>),
4    StructuredTool(name='get_weather', description='Get weather for location.', args_schema={'additionalProperties': False, 'properties': {'location': {'type': 'string'}}, 'required': ['location'], 'type': 'object'}, metadata={'_meta': {'fastmcp': {'tags': []}}}, response_format='content_and_artifact', coroutine=<function convert_mcp_tool_to_langchain_tool.<locals>.call_tool at 0x10c2aa3e0>)
5]

tools 和用如下常规用 from langchain.tools import tool 装饰的函数类型是一样的,都是 StructuredTool

1from langchain.tools import tool
2
3@tool
4def add(a: int, b: int) -> int:
5    """Add two numbers"""
6    return a + b

也就是说 LangChainMCP tools 转换为 LangChain 的 tools.

每个工具有相应的 coroutine, 即实际对应的执行函数. 使用 MCP 时的工具函数的描述是由 MultiServerMCPClient 通过 stdiohttp 获取的。

比如下面的方式可以从 streamable-httpMCP 上获取工具函数的描述:

 1# 先获得 mcp-session-id
 2curl -i 'http://localhost:8000/mcp' \
 3-H 'Accept: application/json, text/event-stream' \
 4-H 'Content-Type: application/json' \
 5--data '{"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"mcp","version":"0.1.0"}},"jsonrpc":"2.0","id":0}'
 6HTTP/1.1 200 OK
 7date: Wed, 22 Apr 2026 21:38:52 GMT
 8server: uvicorn
 9cache-control: no-cache, no-transform
10connection: keep-alive
11content-type: text/event-stream
12mcp-session-id: 9b5fd6e4c7804ca69bf3f970da21cbf7
13x-accel-buffering: no
14Transfer-Encoding: chunked
15
16event: message
17data: {"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-11-25","capabilities":{"experimental":{},"logging":{},"prompts":{"listChanged":true},"resources":{"subscribe":false,"listChanged":true},"tools":{"listChanged":true},"extensions":{"io.modelcontextprotocol/ui":{}}},"serverInfo":{"name":"Weather","version":"3.2.4"}}}
18
19# 再由 mcp-session-id 得到工具列表
20curl 'http://localhost:8000/mcp' \
21-H 'Accept: application/json, text/event-stream' \
22-H 'Content-Type: application/json' \
23-H 'mcp-session-id: 9b5fd6e4c7804ca69bf3f970da21cbf7' \
24--data '{"method":"tools/list","jsonrpc":"2.0","id":1}'
25event: message
26data: {"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"get_weather","description":"Get weather for location.","inputSchema":{"additionalProperties":false,"properties":{"location":{"type":"string"}},"required":["location"],"type":"object"},"outputSchema":{"properties":{"result":{"type":"string"}},"required":["result"],"type":"object","x-fastmcp-wrap-result":true},"_meta":{"fastmcp":{"tags":[]}}}]}}

stdioMCP Server 也类似,只是交互是通过标准输入输出进行的,使用前面 post body 中的内容作为标准输入,见下

1uv run python src/langchain_study/mcp/math_server.py
2[04/22/26 16:45:00] INFO     Starting MCP server 'Math' with transport 'stdio'                                                                                                                                                                              transport.py:209
3{"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"mcp","version":"0.1.0"}},"jsonrpc":"2.0","id":0}
4{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-11-25","capabilities":{"experimental":{},"logging":{},"prompts":{"listChanged":false},"resources":{"subscribe":false,"listChanged":false},"tools":{"listChanged":true},"extensions":{"io.modelcontextprotocol/ui":{}}},"serverInfo":{"name":"Math","version":"3.2.4"}}}
5{"method":"tools/list","jsonrpc":"2.0","id":1}
6{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"add","description":"Add two numbers","inputSchema":{"additionalProperties":false,"properties":{"a":{"type":"integer"},"b":{"type":"integer"}},"required":["a","b"],"type":"object"},"outputSchema":{"properties":{"result":{"type":"integer"}},"required":["result"],"type":"object","x-fastmcp-wrap-result":true},"_meta":{"fastmcp":{"tags":[]}}},{"name":"multiply","description":"Multiply two numbers","inputSchema":{"additionalProperties":false,"properties":{"a":{"type":"integer"},"b":{"type":"integer"}},"required":["a","b"],"type":"object"},"outputSchema":{"properties":{"result":{"type":"integer"}},"required":["result"],"type":"object","x-fastmcp-wrap-result":true},"_meta":{"fastmcp":{"tags":[]}}}]}}

高亮是标准输入的内容,然后看到相应输出就是工具函数列表,带详细描述。

调用工具

模型会告诉客户端调用哪个工具,以及相应的参数,MCP 客户能把它们组成 JSON-RPC 请求的消息,如果对于 httpMCP 服务,进行如下的请求

1curl 'http://localhost:8000/mcp' \
2-H 'Accept: application/json, text/event-stream' \
3-H 'Content-Type: application/json' \
4-H 'mcp-session-id: 9b5fd6e4c7804ca69bf3f970da21cbf7' \
5--data '{"method":"tools/call","params":{"name":"get_weather","arguments":{"location":"nyc"}},"jsonrpc":"2.0","id":1}'
6event: message
7data: {"jsonrpc":"2.0","id":1,"result":{"_meta":{"fastmcp":{"wrap_result":true}},"content":[{"type":"text","text":"It's always sunny in New York"}],"structuredContent":{"result":"It's always sunny in New York"},"isError":false}}

对于 httpMCP 调用就完成了。对于 stdioMCP 服务,也可以猜出来如何调用了

1uv run python src/langchain_study/mcp/math_server.py
2[04/22/26 17:09:39] INFO     Starting MCP server 'Math' with transport 'stdio'                                                                                                                                                                              transport.py:209
3{"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"mcp","version":"0.1.0"}},"jsonrpc":"2.0","id":0}
4{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":"2025-11-25","capabilities":{"experimental":{},"logging":{},"prompts":{"listChanged":false},"resources":{"subscribe":false,"listChanged":false},"tools":{"listChanged":true},"extensions":{"io.modelcontextprotocol/ui":{}}},"serverInfo":{"name":"Math","version":"3.2.4"}}}
5{"method":"tools/call","params":{"name":"add","arguments":{"a":3,"b":5}},"jsonrpc":"2.0","id":1}
6math:add called with a=3 and b=5
7{"jsonrpc":"2.0","id":1,"result":{"_meta":{"fastmcp":{"wrap_result":true}},"content":[{"type":"text","text":"8"}],"structuredContent":{"result":8},"isError":false}}

同样,高亮为标准输入的内容,紧随其后的是输出的结果。

HTTP MCP 传 HTTP Header

要向 http(也是 streamable-http) 或 sseMCP 服务端发送请求头的方法是

 1client = MultiServerMCPClient(
 2    {
 3        "weather": {
 4            "transport": "http",
 5            "url": "http://localhost:8000/mcp",
 6            "headers": {
 7                "Authorization": "Bearer YOUR_TOKEN",
 8                "X-Custom-Header": "custom-value"
 9            },
10        }
11    }
12)

对于 Authentication 应该也能通过请求头的方式发送,但这里有个特殊的参数 auth

1client = MultiServerMCPClient(
2    {
3        "weather": {
4            "transport": "http",
5            "url": "http://localhost:8000/mcp",
6            "auth": auth,
7        }
8    }
9)

auth 是一个 httpx.Auth 实现。

httpMCP 通过回调接口应该可以实现 OAuth 的认证流程,此处不继续深入。参考两个链接:

MultiServerMCPClient 默认无状态

httpMCP 每次调用函数时都会初始化得到一个 mcp-session-id,据此调用相应的方法,stdio 也是无状态的,这符合正常使用 MCP 的需求, 记忆应该维护在 Agent 端. 用 ClientSeesion 可以让 MCP session 变成有状态的,但还是想不出作为一个工具为何要维护状态。

MCP 工具调用的内容

结构化内容

client.get_tools() 返回的 StructuredTool, 它有一个属性 response_format='content_and_artifact', 这意味着 MCP 的工具除了返回适于机器读的内容,还伴随着适于人阅读的内容。

我们窥探一下第一个算 3 + 5ToolMessage 的内容片断

LangChain 中消息的 artifact 中的内容是不会加到会话历史中去的,是给人阅读或记日志用的。图中 content, content_blocks 中的内容才会加到会话历史中去。 如果要把 structured_content 加到与 LLM 交互的会话历史中去,则要用 interceptor. 暂时没想到这种需求,跳过相关的演示代码。

多模态内容

除了文本外,MCP 工具也可以返回图片,音视频之类的,还是对比上一个图,此时内容要放在 content_blocks 中, 这时的 type 就要是 image 之类的。这与 MCP 关系不大,就是一个常规的 Tool 也是同样的。

先不管 MCP, 看如果一个 LangChaintool 返回一个图片内容时,工具函数该返回怎么样的格式

 1import base64
 2
 3import httpx
 4from langchain.agents import create_agent
 5from langchain_core.messages import HumanMessage
 6from langchain_core.tools import tool
 7
 8
 9@tool
10def fetch_image(image_url: str) -> list[dict]:
11    """Fetch image from the web"""
12    response = httpx.get(image_url)
13    content_type = response.headers.get("content-type", "image/png")
14    b64_data = base64.b64encode(response.content).decode("utf-8")
15    return [{"type": "image_url", "image_url": {"url": f"data:{content_type};base64,{b64_data}"}}]
16
17
18agent = create_agent(
19    # "ollama:gemma4:e4b",
20    "bedrock:us.anthropic.claude-haiku-4-5-20251001-v1:0",
21    tools=[fetch_image],
22)
23
24result = agent.invoke({"messages": [HumanMessage(content=(
25    "describe the the image at https://yanbin.blog/my-first-langchain-ai-agent/my-first-ai-agent-cats.png"
26    ))]})
27
28print(result["messages"][-1].content)

这段代码还必须切换到 claude 的模型才能正确识别图片的内容,最后显示对图片的描述信息

 1This image shows a Telegram chat interface with a bot called "Seek Cat." Here's what it displays:
 2
 3**Top portion:**
 4- A Telegram conversation header showing "Seek Cat bot" with a blue circular avatar containing an "S"
 5- A high-quality close-up photo of a tabby cat's face with striking yellow/green eyes and prominent whiskers, timestamped 6:29 PM
 6
 7**Bottom portion:**
 8- A message bubble announcing "🐱 New Cat Found!" with details about a cat named **Ribeye**:
 9  - **Sex:** Male/Neutered
10  - **Breed:** Domestic Shorthair/Mix
11  - **Age:** 1 year 9 months
12  - Links to "View Photo" and "View Full Profile"
13  - Below that is another cat photo showing a tabby and white cat lying in a cat hammock/bed, looking at the camera, also timestamped 6:29 PM
14
15**Background:** The chat window has a light green background with cute cat-themed emoji patterns (cats, fish, watermelons, etc.)
16
17This appears to be a demo or example screenshot from a blog post about creating an AI agent using LangChain, showing how the bot can present information about cats in a Telegram interface.

调试看看 fetch_image() 调用后 ToolMessage 的内容是什么

 1{
 2  "artifact": null,
 3  "content": [{"type": "image_url","image_url":  {"url":"data:image/png;base64,iVBORw0KGgoAAAAN......"}}],
 4  "content_blocks": [{"mime_type": "image/png", "type": "image", "base64": "iVBORw0KGgoAAAAN......", 
 5    "id": "lc_32058fe8-d4b8-4aad-bdfa-e0b88473becb"}],
 6  "name": "fetch_image",
 7  "text": "",
 8  "tool_call_id": "toolu_bdrk_01VFAFDYiu8jjnEb4Vf6QmkW",
 9  "type": "tool"
10}

LangChain@tool 可指定属性 @tool(response_format="content_and_artifact"), 在方法中返回一个 tuple, 前为 content, 后 为 artifact.

如果一个 MCP 工具方法获取图片数据的话是否也要返回相同的数据格式呢? 尝试写成如下的 MCP tool

 1@mcp.tool()
 2def fetch_image(image_url: str) -> ImageContent:
 3    """Fetch image from the web"""
 4    print(f"Image:fetch_image called with {image_url=}", file=sys.stderr)
 5    response = httpx.get(image_url)
 6    content_type = response.headers.get("content-type", "image/png")
 7    b64_data = base64.b64encode(response.content).decode("utf-8")
 8
 9    return ImageContent(
10            type="image",
11            data=b64_data,
12            mimeType=content_type
13        )

可是失败了,生成的 ToolMessagecontent_blocks 的内容和上面一样,符合预期,可是 content 也和 content_blocks 一样。

MCP 的资源与提示词

MCPResourcesPrompts 很类似,都是 MCP 暴露的资源(比如静态文件) 只是它们的用途不同,它们在改变时可激发事件,客户端可订阅它们的变化事件。 而且获取方式也相近, 通过 MultiServerMCPClient 的方法

  • Resources: get_resources(server_name, uris), 通过 uris 读取资源的内容
  • Prompts: get_prompts(server_name, prompt_name, arguments), 通过 prompt_name 读取提示词的内容, 如果是一个模板用 arguments 字典填充

我们可遵循 MCP 的规范用 JSON-RPC 协议列出 Resources, Resource 模板, Prompts. 创建 @mcp.resource()@mcp.prompt() 很像是在定义 RESTFul API 一样,

Resource 有参数时用占位符,如

1@mcp.resource("resource://users/{user_id}")
2def get_user(user_id: str) -> str:
3    ...

Prompt 有参数时,声明为函数参数即可

1@mcp.prompt()
2def translate(text: str, target_lang: str = "English") -> str:
3    ...

MCP 工具调用拦截器

Agent 有中间件一样,MultiServerMCPClient 也有类似的机制,叫做拦截器(Tool Interceptors). 下面是拦截器的配置与相应方法的原型

 1async def mcp_tool_interceptor(request: MCPToolCallRequest, handler):
 2    modified_request = request.override(
 3        args={**(request.args | { "a":100})}
 4    )
 5
 6    return await handler(modified_request)
 7
 8client = MultiServerMCPClient(
 9    {...},
10    tool_interceptors=[mcp_tool_interceptor],
11)

这是一个典型的 filter/interceptor 的模式,在这里有所发挥的事有

  1. 能从 request: MCPToolCallRequest 拿到什么,并进行什么操作
  2. request.override() 可以覆盖什么

首先,request: MCPToolCallRequest 有以下属

MCPToolCallRequest 可以拿到 args(参数),headers(HTTP 的 MCP 的请求头), name(工具名称),ToolRuntime, context, state(AgentState), 和 Store 等信息.

request.override() 时可以覆盖

  1. name: 工具名称,可以决定调用另一个方法
  2. args: 工具参数,比如从 contextstate 中获取一些信息覆盖调用 MCP 工具时的参数
  3. headers: 对 httpMCP 工具调用时覆盖请求头

这里的例子,在调用 add(a: int, b: lint) 时把 a 参数覆盖为 100, 最后实际调用的是 100 + <b 的值>。

从拦截函数中可以返回 Command 对象来更新 agent 的状态,上篇 LangChain 核心组件之短期记忆 我们在普通的工具可以返回 Command

 1@tool
 2def where_is_bob(runtime:  ToolRuntime) -> str:
 3    """Tell Bob where he is."""
 4    # print(runtime.state["messages"])
 5
 6    if runtime.context.user_id == "123":
 7        return Command(update={
 8            "messages": [
 9                ToolMessage("he is in SF", tool_call_id=runtime.tool_call_id)
10            ]
11        })
12    return "he is in Chicago"

mcp_tool_interceptor 中可以做同样的事情

 1async def mcp_tool_interceptor(request: MCPToolCallRequest, handler):
 2    result = await handler(request)
 3
 4    if request.name == "submit_order":
 5        return Command(
 6            update={
 7                "messages": [result] if isinstance(result, ToolMessage) else [],
 8                "task_status": "completed",
 9            },
10            goto="summary_agent",  # goto="__end__" 可提前结束执行
11        )
12
13    return result

拦截器中想要实现的就是拦截对 handler(request) 的调用,所以在调用它的时候可以捕获异常进行重试,或进行其他的异常处理。

MCP 方法调用的进度通知

调用一个耗时的 MCP 工具时,可以使用回调函数来报告进度,但官方的那个例子根本没法直接用,由于 LangChain 1.x 还太新,Google 和各路 AI 都没法做不出一个可用的进度通知的完整例子。

因为 LangChainMCPAdapter 是适配给 FastMCP 的,先看只用 FastMCP 如何实现进度通知,显示。这是一个学习笔记,不是专题, 又要开始大篇幅的展示代码了。

web_fetch.py

 1import asyncio
 2
 3from fastmcp import FastMCP, Context
 4
 5mcp = FastMCP("web_fetch")
 6
 7@mcp.tool()
 8async def fetch(ctx: Context, url: str) -> str:
 9    """fetch web string content by url"""
10    await ctx.report_progress(30, total=100, message=f"loading {url=}")
11    await asyncio.sleep(1)
12    await ctx.report_progress(65, total=100, message=f"loading {url=}")
13    await asyncio.sleep(1)
14    await ctx.report_progress(100, total=100, message=f"loaded {url=}")
15    return "I'm a cat"
16
17if __name__ == "__main__":
18    mcp.run(transport="stdio", show_banner=False)

fetch() 函数必须是 async 的,因为 ctx.report_progress() 是异常函数,至于 ctx: Context 放在前面后面都没关系

mcp_client.py

 1import asyncio
 2
 3from fastmcp import Client
 4
 5
 6async def progress_handler(progress: float, total: float | None, message: str) -> None:
 7    print(f"progress: {progress}/{total} - {message}")
 8
 9async def main():
10    client = Client("web_fetch.py", progress_handler=progress_handler)
11
12    async with client:
13        result = await client.call_tool("fetch", {"url": "https://yanbin.blog"})
14        print(result)
15
16if __name__ == "__main__":
17    asyncio.run(main())

创建 Client 时用 progress_handler 指定进度通知回调函数,Client 会自动在调用 MCP 工具时,收到工具中 ctx.report_progress() 通知调用这个回调函数,传入进度信息。下面将进行多轮测试

第一轮测试 stdio

基于上面的代码,执行 mcp_client.py 的输出如下

1progress: 30.0/100.0 - loading url='https://yanbin.blog'
2progress: 65.0/100.0 - loading url='https://yanbin.blog'
3progress: 100.0/100.0 - loaded url='https://yanbin.blog'
4CallToolResult(content=[TextContent(type='text', text="I'm a cat", annotations=None, meta=None)], structured_content={'result': "I'm a cat"}, meta={'fastmcp': {'wrap_result': True}}, data="I'm a cat", is_error=False)

第二轮测试 http/streamable-http

web_fetch.py 改成 http 模型,__main__ 部分改为

1if __name__ == "__main__":
2    mcp.run(transport="http", show_banner=False) # 用 'streamable-http' 也是一样的

启动

1uv run python src/langchain_study/mcp/web_fetch.py
2[04/23/26 21:13:21] INFO     Starting MCP server 'web_fetch' with transport 'http' on http://127.0.0.1:8000/mcp 

mcp_client.py 中,创建 Client 的行改为

1client = Client("http://localhost:8000/mcp", progress_handler=progress_handler)

执行后,一样的效果,可输出进度。

第三轮测试, sse

web_fetch.py 改成 sse 模型,__main__ 部分改为

1if __name__ == "__main__":
2    mcp.run(transport="sse", show_banner=False)

启动

1uv run python src/langchain_study/mcp/web_fetch.py
2[04/23/26 21:16:23] INFO     Starting MCP server 'web_fetch' with transport 'sse' on http://127.0.0.1:8000/sse 

mcp_client.py 中,创建 Client 的行改为

1client = Client("http://localhost:8000/sse", progress_handler=progress_handler)

测试,能正常输出进度。

LangChain 中的进度报告

现在可以移步回 LangChain, 首先恢复 web_fetch.py__main__ 部分为

1if __name__ == "__main__":
2    mcp.run(transport="stdio", show_banner=False)

创建 langchain_mcp_client.py, 内容为

 1import asyncio
 2
 3from langchain.agents import create_agent
 4from langchain_mcp_adapters.callbacks import CallbackContext, Callbacks
 5from langchain_mcp_adapters.client import MultiServerMCPClient
 6
 7
 8async def on_progress(progress: float, total: float | None, message: str | None, context: CallbackContext):
 9    print(f"{context.server_name}:{context.tool_name} - progress: {progress}/{total} - {message}")
10
11async def main():
12    client = MultiServerMCPClient(
13        {
14            "web_fetch": {
15                "transport": "stdio",
16                "command": "python",
17                "args": ["web_fetch.py"],
18            }
19        },
20        callbacks=Callbacks(on_progress=on_progress),
21    )
22
23    tools = await client.get_tools()
24    agent = create_agent(
25        "ollama:gemma4:e4b",
26        tools=tools,
27    )
28
29    await agent.ainvoke({"messages": [{"role": "user", "content": "read web content from https://yanbin.blog"}]})
30
31
32if __name__ == "__main__":
33    asyncio.run(main())

执行 langchain_mcp_client.py 的输出如下

1uv run python src/langchain_study/mcp/langchain_mcp_client.py
2[04/23/26 21:23:37] INFO     Starting MCP server 'web_fetch'    transport.py:209
3with transport 'stdio'                             
4[04/23/26 21:23:40] INFO     Starting MCP server 'web_fetch'    transport.py:209
5with transport 'stdio'                             
6web_fetch:fetch - progress: 30.0/100.0 - loading url='https://yanbin.blog'
7web_fetch:fetch - progress: 65.0/100.0 - loading url='https://yanbin.blog'
8web_fetch:fetch - progress: 100.0/100.0 - loaded url='https://yanbin.blog'

stdio 没问题,on_progress() 方法中必须是按顺序的四个参数 progress: float, total: float | None, message: str | None, 和 context: CallbackContext

尝试 http 模式,把 web_fetch.py 改成 transport="http", 并启动服务在 http://localhost:8000/mcp. 然后把 langchain_mcp_client.py 中创建 MultiServerMCPClient 的代码改为

1    client = MultiServerMCPClient(
2        {
3            "web_fetch": {
4                "transport": "http",
5                "url": "http://localhost:8000/mcp"
6            }
7        },
8        callbacks=Callbacks(on_progress=on_progress),
9    )

测试 http(或 streamable-http), 可以看到进度输出.

再接再厉,把 web_fetch.py 改成 transport="sse" 模式,启动服务在 http://localhost:8000/sse. langchain_mcp_client.py 中创建 MultiServerMCPClient 的代码改为

1    client = MultiServerMCPClient(
2        {
3            "web_fetch": {
4                "transport": "sse",
5                "url": "http://localhost:8000/sse"
6            }
7        },
8        callbacks=Callbacks(on_progress=on_progress),
9    )

运行 langchain_mcp_client.py, 没问题

1uv run python src/langchain_study/mcp/langchain_mcp_client.py 
2web_fetch:fetch - progress: 30.0/100.0 - loading url='https://yanbin.blog'
3web_fetch:fetch - progress: 65.0/100.0 - loading url='https://yanbin.blog'
4web_fetch:fetch - progress: 100.0/100.0 - loaded url='https://yanbin.blog'

使用 http 模式的输出与 sse 模式输出一致。

订阅 MCP 工具中的日志输出

特别是对于 stdio 模式的 MCP 工具,如果用 print() 来调试代码,可能一不小心就把工具函数毁坏了,因为 print() 到控制台的信息会作为工具调用输出的一部分。 所以要特别小心,或者可以输出到标准错误或文件去,print("debug info", file=sys.stderr), 就是千万不要向标准输出打印调试信息。

除了打印到标准错误,或文件外 - 这些都只能在服务端看日志,MCP 还提供服务端输出日志,客户订阅服务端的日志内容,还是用 fastmcp.Context, 把 web_fetch.py 的工具函数改为

 1@mcp.tool()
 2async def fetch(ctx: Context, url: str) -> str:
 3    """fetch web string content by url"""
 4
 5    await ctx.debug(f"fetch debug log for {url=}", extra={"foo": "bar"})
 6    await ctx.info(f"fetch called with {url=}")
 7    await ctx.warning(f"fetch debug log for {url=}")
 8    await ctx.error(f"fetch debug log for {url=}")
 9
10    return "I'm a cat"

sse 的方式启动 web_fetch 服务, 然后完整的 langchain_mcp_client.py 改为

 1import asyncio
 2
 3from langchain.agents import create_agent
 4from langchain_mcp_adapters.callbacks import CallbackContext, Callbacks, LoggingMessageNotificationParams
 5from langchain_mcp_adapters.client import MultiServerMCPClient
 6
 7async def on_logging_message(params: LoggingMessageNotificationParams, context: CallbackContext):
 8    print(f"[{context.server_name}] {params.level}: {params.data}")
 9
10async def main():
11    client = MultiServerMCPClient(
12        {
13            "web_fetch": {
14                "transport": "sse",
15                "url": "http://localhost:8000/sse"
16            }
17        },
18        callbacks=Callbacks(on_logging_message=on_logging_message),
19    )
20
21    tools = await client.get_tools()
22    agent = create_agent("ollama:gemma4:e4b", tools=tools)
23
24    await agent.ainvoke({"messages": [{"role": "user", "content": "read web content from https://yanbin.blog"}]})
25
26
27if __name__ == "__main__":
28    asyncio.run(main())

执行 langchain_mcp_client.py 就看到输出

1[web_fetch] debug: {'msg': "fetch debug log for url='https://yanbin.blog'", 'extra': {'foo': 'bar'}}
2[web_fetch] info: {'msg': "fetch called with url='https://yanbin.blog'", 'extra': None}
3[web_fetch] warning: {'msg': "fetch debug log for url='https://yanbin.blog'", 'extra': None}
4[web_fetch] error: {'msg': "fetch debug log for url='https://yanbin.blog'", 'extra': None}

MCP 工具启发式询问 Elicitation

MCP 工具的执行过程中,还能与 MCP 客户端进行交互,例如补充输入等,有点类似 Human-in-the-loop. 官方对这个话题倒是给出了完整的例子, 可是 ctx.elicit() 的第二个参数名不是 schema, 而是 response_type.

新的 web_fetch.py 内容为

 1from fastmcp import FastMCP, Context
 2from pydantic import BaseModel
 3
 4mcp = FastMCP("web_fetch")
 5
 6class QueryParams(BaseModel):
 7    id: int
 8    date: str
 9
10@mcp.tool()
11async def fetch(ctx: Context, url: str) -> str:
12    """fetch web string content by url"""
13
14    result = await ctx.elicit(message=f"Please provide id and date", response_type=QueryParams)
15    if result.action == "accept":
16        print(f"fetch called with {url=} and {result.data=}")
17    if result.action == "decline":
18        print(f"fetch called with {url=} but user declined to provide query params")
19    return f"I'm a cat: {result}"
20
21if __name__ == "__main__":
22    mcp.run(transport="sse", show_banner=False)

改完,启动服务在 http://localhost:8000/sse

新的 langchain_mcp_client.py 内容为

 1import asyncio
 2
 3from langchain.agents import create_agent
 4from langchain_mcp_adapters.callbacks import CallbackContext, Callbacks, LoggingMessageNotificationParams
 5from langchain_mcp_adapters.client import MultiServerMCPClient
 6from mcp.shared.context import RequestContext
 7from mcp.types import ElicitRequestParams, ElicitResult
 8
 9
10async def on_elicitation(mcp_context: RequestContext, params: ElicitRequestParams,
11        context: CallbackContext, ) -> ElicitResult:
12    return ElicitResult(
13        action="accept",
14        content={"id": 12345, "date": "2022-12-23"},
15    )
16
17async def main():
18    client = MultiServerMCPClient(
19        {
20            "web_fetch": {
21                "transport": "sse",
22                "url": "http://localhost:8000/sse"
23            }
24        },
25        callbacks=Callbacks(on_elicitation=on_elicitation),
26    )
27
28    tools = await client.get_tools()
29    agent = create_agent("ollama:gemma4:e4b", tools=tools)
30
31    result = await agent.ainvoke({"messages": [{"role": "user", "content": "read web content from https://yanbin.blog"}]})
32    print(result["messages"][-1].content)
33
34
35if __name__ == "__main__":
36    asyncio.run(main())

ElicitResult 支持的 action 仅限三个, accept, decline, 或 cancel.

上面的程序执行后,输出为

1uv run python src/langchain_study/mcp/langchain_mcp_client.py 
2I attempted to read the web content from `https://yanbin.blog`, but the data retrieved appears to be technical or incomplete, and does not contain the readable blog content.
3
4The content I received was:
5`I'm a cat: action='accept' data=QueryParams(id=12345, date='2022-12-23')`
6
7If you were looking for specific information, please let me know, and I can try a different approach.

MCP 工具执行工作询问客户端给出更多的输入,客户提供额外的输入,最后完成工具的调用。在

1async def on_elicitation(mcp_context: RequestContext, params: ElicitRequestParams,
2        context: CallbackContext, ) -> ElicitResult:

我们能从参数中读取到更多的用户或应用信息,在这里也能要求获取用户的一个交互输入。

注意到以上都是设置的 MultiServerMCPClientcallbacks 参数,它是一个 Callbacks 实例,函数三个属性 on_logging_message, on_elicitation, on_progress, 分别对应 MCP 的日志通知,启发式询问,和进度通知, 我们能在一个 callbacks 参数赋予多个回调函数。

这就是 LangChain 关于 MCP 的全部内容了,更多的信息应该参考以下两个链接

  1. Model Context Protocol(MCP) 的官方文档
  2. FastMCP 的官方文档
永久链接 https://yanbin.blog/langchain-advanced-usage-mcp/, 来自 隔叶黄莺 Yanbin's Blog
[版权声明] 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。