AI Agent 原理与实现(四):MCP 的原理
前言
在 AI Agent 原理与实现(二):工具调用 里,我介绍了工具调用的概念:模型生成一条结构化的调用请求,Agent 执行对应的函数,再把结果返回给模型。工具调用让模型具备了执行外部操作的能力。
在工具调用的例子里,LLM 能使用的工具都是开发者提前在 Agent 里注册好的。比如一个查询天气的工具,Agent 里可能有这样的代码:
def get_weather(city: str, date: str) -> dict:
return weather_service.query(city=city, date=date)
每次提供一个新工具,都要在 Agent 里写一份函数、一份工具描述,然后注册进 Agent。如果多个团队在开发不同的 Agent,他们都要使用同样的工具,就会出现重复造轮子的情况。MCP(Model Context Protocol,模型上下文协议)解决的就是这个问题。它把“工具的定义和实现”从 Agent 代码里拆出来,变成一个独立的、可复用的服务,用一套统一协议供任何 Agent 接入。MCP 被称为“AI 世界的 USB 接口”,工具和 Agent 之间的连接因此标准化、即插即用。
从 Agent 分离工具定义与实现
如果我们启动一个 Web 服务,通过 HTTP API 暴露 /tools 接口,返回一组工具的定义和描述,并暴露 /call 接口,接收工具调用请求并执行对应的操作。
GET /tools
[
{
"name": "query_database",
"description": "查询公司数据库",
"parameters": { ... }
},
{
"name": "list_github_repos",
"description": "列出 GitHub 仓库",
"parameters": { ... }
},
{
"name": "read_local_file",
"description": "读取本地文件",
"parameters": { ... }
}
]
POST /call
{
"tool": "query_database",
"arguments": {
"sql": "SELECT * FROM users WHERE id = 123"
}
}
那么 Agent 就可以在运行时动态获取工具定义,而不需要在代码里硬编码。而 LLM 也可以根据 /tools 接口返回的工具描述,生成对应的调用请求。当 LLM 生成了一个工具调用请求,Agent 就可以把请求转发给 /call 接口,由服务端执行对应的操作。如此一来,工具的定义和实现就从 Agent 里拆出来,变成一个独立的服务。
所有 Agent 都可以像调用 RESTful API 一样调用远程工具,而不需要各自重复实现。
什么是 MCP
前面我介绍了一种将工具定义和实现从 Agent 里拆出来的方式:通过 HTTP API 暴露工具定义和调用接口,Agent 在运行时动态获取工具定义,并把调用请求转发给服务端执行。这个方式已经可以让 Agent 复用工具了,但还需要规定一套标准的协议,把工具发现和调用的流程规范化。MCP(Model Context Protocol,模型上下文协议)定义了这套标准协议,统一 Agent 和工具服务之间的通信方式、数据格式。
MCP 由 Anthropic 于 2024 年 11 月发布,是一个开放标准,目前已经被主流的 Agent 框架和工具服务广泛采用。MCP 的目标是让 AI 工具生态从“各写各的”走向“即插即用”,让模型调用远程工具就像调用本地函数。MCP 提供了一个标准化的方式,让 Agent 可以发现、连接和调用工具服务,而不需要每个 Agent 都实现一套自己的工具调用逻辑。
MCP 的三方角色
MCP 涉及三种角色:Host、Client 和 Server。
- Host(宿主):用户直接接触的应用,比如 Claude Desktop、Cursor 这样的 IDE,或者你自己写的 Agent 程序。Host 是入口,它决定要连接哪些 MCP Server,管理整个会话的生命周期,并在需要时调用 LLM。
- Client(客户端):Host 内部的一个组件,每个 Client 与一个 MCP Server 建立一对一的连接。Client 负责按照 MCP 协议与 Server 通信。一个 Host 里可以有多个 Client,分别连接不同的 Server。
- Server(服务端):独立运行的程序,负责暴露具体的能力:工具、数据、提示词模板。Server 自己管理数据源和外部系统的访问,对 Client 只暴露标准化的接口。
Host 与 Server 不直接通信,中间一定有一个 Client。一个 Host 想接入两个 Server,就要创建两个 Client。Host 负责"决策",Client 负责"通信",Server 负责"干活"。
MCP Server 暴露的三类能力
一个 MCP Server 可以暴露三类东西:Tools、Resources 和 Prompts。
Tools:工具
Tools 是 Server 暴露的可调用函数,是模型可以主动调用的能力。这里的工具定义和实现都封装在 Server 里,Agent 启动时通过 MCP Client 获取工具列表,并将工具描述注入到 LLM 的上下文里。
一个查询数据库的 MCP Server,可能暴露这样的工具:
{
"name": "query",
"description": "对数据库执行只读 SQL 查询",
"inputSchema": {
"type": "object",
"properties": {
"sql": {
"type": "string",
"description": "要执行的 SQL 语句,仅支持 SELECT"
}
},
"required": ["sql"]
}
}
模型看到这个工具描述后,就能在需要时生成对应的调用请求。Host 收到请求后,通过 Client 转发给 Server,Server 执行 SQL,把结果返回。此时 Agent 就拿到了工具调用的结果,这和执行本地函数是一样的。
Resources:资源
Resources 是 Server 向 Client 暴露数据、文件或系统状态的方式,主要用于给 LLM 提供只读的外部上下文信息。每个资源都有一个唯一的 URI,Client 通过这个 URI 请求资源内容。
Client 可以获取资源列表,了解 Server 提供了哪些资源。当模型或用户需要某个资源时,Client 向 Server 请求该资源的内容。Client 还可以订阅资源的更新通知,在资源发生变化时及时获取最新内容。
资源如何被 LLM 使用,取决于应用的设计:用户可以手动选择资源,LLM 也可以自动选择资源。比如一个代码审查应用,可能会把代码库的文件作为资源暴露给 LLM,LLM 在生成审查意见时,根据需要选择读取哪些文件。
Prompts:提示词模板
Prompts 是 Server 暴露的提示词模板,预定义了某种场景下该怎么跟模型说话。Client 使用 prompts/list 接口获取可用的 prompt 列表,列表描述了每个 prompt 的名称、用途和所需参数,比如:
[
{
"name": "translate",
"description": "将文本翻译为指定语言",
"arguments": [
{
"name": "text",
"type": "string",
"description": "要翻译的文本"
},
{
"name": "target_language",
"type": "string",
"description": "目标语言代码,例如 'en'、'zh'、'fr'"
}
]
}
]
Client 要某个 prompt 时,通过 prompts/get 接口传入参数,获取 prompt 的内容。prompt 内容通常是一个字符串模板,里面包含占位符,Client 把用户输入填充进去,再发送给 LLM 生成结果。
三者的关系
| 类型 | 谁使用 | 本质 | 类比 |
|---|---|---|---|
| Tools | 模型 | 可执行的动作 | 函数 |
| Resources | 模型或人 | 可读取的数据 | 文件 |
| Prompts | 人 | 预定义的对话方式 | 模板 |
目前大多数 MCP Server 主要暴露 Tools,Resources 和 Prompts 使用得较少。因为 Resources 的读取其实也可以通过 Tools 来实现,而 prompt 目前主要是由基于 LLM 的应用自己定义的,MCP Server 暴露 prompt 的场景还不多。
MCP 部署方式
MCP Server 有两种部署方式,一种是本地部署在用户自己的机器上,另一种是远程部署在云端服务器上。部署在本地的 Server 可以直接访问本地资源,比如本地文件系统。部署在远程的 Server 通常是第三方的服务,比如 GitHub、Slack、Google Drive 等,用于访问云端资源。
本地 MCP Server
本地 Server 运行在用户自己的机器上,通过标准输入输出(stdio)与 Client 通信。Client 把请求写入 Server 的标准输入,从标准输出读取响应。
Client ──(stdin)──> Server 进程
Client <──(stdout)── Server 进程
虽然这里叫做 Server,但把它想象为一个进程更准确。Server 进程启动后,监听标准输入,等待 Client 发来的请求。Client 负责把请求序列化成 JSON,通过标准输入发送给 Server,Server 执行请求后,把结果序列化成 JSON,通过标准输出返回给 Client。
本地 Server 适合访问本地资源:本地文件系统、本地数据库、本地 API。因为 Server 和 Client 在同一台机器上,不需要网络传输,延迟低,配置也简单。比如一个读取本地文件的 MCP Server,可以直接读用户硬盘上的文件,不需要经过网络。
远程 MCP Server
远程 Server 运行在远程服务器上,通过 HTTP(通常是 Streamable HTTP)与 Client 通信。Client 发送 HTTP 请求,Server 返回 HTTP 响应。
Client ──(HTTP POST)──> 远程 Server
Client <──(HTTP 200 + JSON)── 远程 Server
远程 Server 适合访问远程服务:云端数据库、企业 API、SaaS 平台。比如一个 GitHub MCP Server 部署在 GitHub 官方服务器上,用户只需要提供 token,就能让任何 Agent 访问自己的仓库。
两种方式对 Host 来说基本透明。Host 只需要知道 Server 的类型和地址,Client 会根据类型选择合适的通信方式;Host 通过 Client 获取工具列表、发送调用请求、接收结果,不需要关心 Server 是本地还是远程,也不需要关心通信细节。
MCP 的请求流程
无论本地还是远程部署,Client 和 Server 之间传输的都是同一种消息格式:JSON-RPC 2.0。每条消息都是一个 JSON 对象,包含 jsonrpc(固定为 "2.0")、method(方法名)、params(参数)和 id(请求标识)几个字段。在 stdio 模式下,每条消息独占一行,以换行符分隔。Client 发出的每条请求都带有 id,Server 的响应会带上相同的 id,Client 靠它把请求和响应对应起来。
一次完整的 MCP 会话可以分为四个阶段:握手、获取工具列表、调用工具、返回结果。
握手:initialize
Client 和 Server 建立连接后,第一件事是握手。握手的目的是让双方确认三件事:使用哪个版本的协议、各自支持哪些能力、分别是什么程序。
Client 先发送 initialize 请求:
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {
"roots": {
"listChanged": true
}
},
"clientInfo": {
"name": "my-agent",
"version": "1.0.0"
}
}
}
三个字段分别表示:
protocolVersion:Client 支持的协议版本。MCP 的协议版本用日期表示,比如2025-06-18。capabilities:Client 支持的能力。比如roots.listChanged表示当 Client 管理的文件系统根目录发生变化时,会主动发送通知。clientInfo:Client 的名称和版本,方便 Server 记录日志和排查问题。
Server 收到请求后,返回自己的信息:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {
"tools": {
"listChanged": true
}
},
"serverInfo": {
"name": "file-server",
"version": "1.0.0"
},
"instructions": "这个 Server 提供本地文件的读写工具"
}
}
protocolVersion:双方实际使用的协议版本。如果 Client 请求的版本 Server 不支持,Server 会返回自己支持的版本,由 Client 决定是使用该版本还是断开连接。capabilities:Server 支持的能力。比如tools.listChanged表示当工具列表发生变化时,Server 会主动通知 Client。serverInfo:Server 的名称和版本。instructions(可选):Server 给 LLM 的使用说明,Client 可以把它加进上下文,帮助模型更好地理解如何使用这个 Server。
收到握手响应后,Client 再发送一条 notifications/initialized 通知,表示"我收到了你的信息,我们正式开始了"。通知(notification)和请求的区别是:通知没有 id 字段,接收方不需要回复。
{
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
发送完这条通知,握手就完成了,Client 和 Server 进入正常工作状态。
获取工具列表:tools/list
握手完成后,Client 发送 tools/list 请求,获取 Server 暴露的所有工具:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
}
Server 返回工具列表,每个工具包含名称、描述和参数定义(JSON Schema):
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "read",
"description": "读取文件内容",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "文件路径"
}
},
"required": ["path"]
}
},
{
"name": "write",
"description": "写入文件内容",
"inputSchema": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "文件路径"
},
"content": {
"type": "string",
"description": "写入的内容"
}
},
"required": ["path", "content"]
}
}
]
}
}
Host 拿到工具列表后,把工具描述注入 LLM 的上下文。从这一刻起,LLM 就知道有哪些工具可用、每个工具需要什么参数。
调用工具:tools/call
当 LLM 在对话中决定使用某个工具时,它会生成一个结构化的调用请求。Host 通过 Client 把它转发给 Server,对应 tools/call 方法:
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "read",
"arguments": {
"path": "example.txt"
}
}
}
Server 执行对应的操作,返回结果:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "Hello, MCP!"
}
]
}
}
小结
一次完整的 MCP 会话流程可以概括为:
- 建立连接:Client 根据 Server 的类型,通过 stdio 或 HTTP 建立通信通道。
- 握手:Client 发送
initialize请求,双方交换协议版本、能力和基本信息,Client 再发送notifications/initialized通知完成握手。 - 获取工具:Client 发送
tools/list请求拿到工具列表,Host 把工具描述注入 LLM 上下文。 - 调用工具:LLM 生成调用请求,Client 发送
tools/call,Server 执行并返回结果,结果送回 LLM。
其中握手是 MCP 协议的第一步,作用类似 TCP 建立连接时的三次握手:双方先对齐"说同一种语言"——确认使用哪个版本的协议、各自能做什么。只有握手成功,后续的 tools/list、tools/call 才有意义。
MCP 实战
下面通过一个简单的例子,演示如何构建一个可以读写本地文件的 MCP Server,并让一个 Agent 通过 MCP Client 调用它。
我使用 FastMCP 这个开源框架来实现 MCP Server,它让定义工具、资源和提示词变得简单,并自动生成 MCP 协议的接口。下面是一个简单的 MCP Server,暴露了两个工具:read 和 write。
# fs/fs.py
from fastmcp import FastMCP
mcp = FastMCP()
@mcp.tool
def read(path):
"""
读取文件内容
:param path: 文件路径
:return: 文件内容
"""
with open(path, 'r', encoding='utf-8') as f:
return f.read()
@mcp.tool
def write(path, content):
"""
写入文件内容
:param path: 文件路径
:param content: 写入的内容
:return: 写入结果
"""
with open(path, 'w', encoding='utf-8') as f:
f.write(content)
if __name__ == "__main__":
mcp.run()
下面代码使用 FastMCP 的 Client 连接这个 Server,先调用 write 工具写入文件,再调用 read 工具读回内容。
# client.py
from fastmcp import Client
client = Client("./fs/fs.py")
async def main():
async with client:
tools = await client.list_tools()
print("可用工具列表:")
for tool in tools:
print(f"- {tool.name} {tool.description}")
# 调用 write 工具写入文件内容
file_path = "example.txt"
await client.call_tool("write", {"path": file_path, "content": "Hello, MCP!"})
# 调用 read 工具读取文件内容
result = await client.call_tool("read", {"path": file_path})
print(f"文件 {file_path} 的内容是:\n{result.content[0].text}")
import asyncio
asyncio.run(main())
运行完成后,输出如下:
可用工具列表:
- read 读取文件内容
- write 写入文件内容
文件 example.txt 的内容是:
Hello, MCP!
你可以在 Agent 的配置文件里指定 MCP Server 的路径和启动命令,Agent 启动时会自动连接。下面是 opencode 的配置文件示例,因为我把 MCP Server 放在本地,所以使用 local 类型,并指定启动命令。
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"my-server": {
"type": "local",
"command": ["bash", "/home/stephenwang/code/my-mcp-server/run.sh"],
"enabled": true
}
}
}
启动 Agent 后,它会自动连接 MCP Server,获取工具列表,并把工具描述注入 LLM 上下文。
总结
MCP 把工具的定义和实现从 Agent 代码里拆出来,变成独立、可复用的服务,再用一套标准协议统一了工具发现和调用的方式。MCP 提供了三类能力:Tools、Resources 和 Prompts。三类能力中,Tools 用得最多,Resources 和 Prompts 目前使用得较少。所以,你可以把 MCP 理解为一个远程函数调用协议,模型调用远程工具就像调用本地函数。