Appearance
第3章 JSON-RPC 与消息格式
任何网络协议的核心问题都可以归结为一句话:如何在两个端点之间可靠地交换结构化信息。HTTP 用请求-响应模型解决了 Web 的通信问题,gRPC 用 Protocol Buffers 解决了微服务的高性能通信问题,而 MCP 选择了一条看似朴素却深思熟虑的路径——JSON-RPC 2.0。
本章将从协议选型的底层逻辑出发,逐层剖析 MCP 消息格式的设计细节,最终深入 TypeScript SDK 的 Protocol 类实现,揭示消息路由、超时管理和通知去抖动等工程机制的内在原理。
3.1 为什么是 JSON-RPC 2.0
在理解 MCP 的消息格式之前,我们需要先回答一个更根本的问题:为什么不用 REST,为什么不用 gRPC,偏偏选了一个看起来有些"古老"的 JSON-RPC?
3.1.1 REST 的局限
REST 是 Web 开发者最熟悉的通信范式,但它天然地绑定在 HTTP 的请求-响应模型上。一个 REST API 的交互模式是严格单向的:客户端发起请求,服务端返回响应。这在传统的 Web 应用中不成问题,但在 MCP 的场景下却构成了根本性的矛盾。
MCP 要求双向通信。服务端不仅要响应客户端的请求(比如"列出你能提供的工具"),还需要主动向客户端发起请求(比如"我需要用户确认这个操作"或"请帮我调用一次 LLM")。REST 做不到这一点——或者说,要做到就需要引入 WebSocket、长轮询等额外机制,把本该简单的协议复杂化。
3.1.2 gRPC 的过重
gRPC 基于 HTTP/2 和 Protocol Buffers,在性能和类型安全方面无可挑剔。它支持双向流(bidirectional streaming),理论上完全能满足 MCP 的双向通信需求。但 gRPC 的问题在于门槛和生态。
首先,Protocol Buffers 是二进制格式,人类不可读。在调试 AI Agent 与工具交互的过程中,开发者无法直接在终端里查看消息内容,必须借助专门的解码工具。其次,gRPC 强依赖 HTTP/2,这意味着它无法在标准输入/输出(stdio)这样的简单管道上运行。而 MCP 的设计目标之一恰恰是支持 stdio 传输——一个 MCP 服务端可以只是一个通过 stdin/stdout 通信的子进程。
3.1.3 JSON-RPC 的恰到好处
JSON-RPC 2.0 规范只有寥寥几页。它定义了三种消息类型(请求、响应、通知),规定了 JSON 作为编码格式,仅此而已。这种极简主义恰好匹配了 MCP 的设计哲学:
传输无关性:JSON-RPC 不绑定任何传输层协议。同样的消息格式可以跑在 stdio 上、HTTP 上、WebSocket 上,甚至通过剪贴板传递。MCP 利用这一特性,在规范层面彻底解耦了消息格式与传输方式。
天然的双向性:JSON-RPC 没有"客户端"和"服务端"的概念——它只有"发送请求的一方"和"接收请求的一方"。任何一方都可以发送请求,任何一方都可以发送通知。这与 MCP 的双向通信需求完美契合。
人类可读:JSON 是文本格式,开发者可以直接阅读、复制、修改消息内容。在调试 AI 系统时,这种透明性的价值远超那几个百分点的序列化性能差距。
生态广泛:几乎所有编程语言都有成熟的 JSON 解析库,实现一个 JSON-RPC 客户端或服务端的门槛极低。这对于一个希望被广泛采用的开放协议来说至关重要。
3.2 三种消息类型
JSON-RPC 2.0 定义了三种消息类型,MCP 严格遵循这一规范。理解这三种类型是理解整个 MCP 通信模型的基础。
3.2.1 Request(请求)
请求是需要对方回复的消息。它必须包含一个唯一的 id,用于将后续的响应与这个请求关联起来。在 MCP 的 TypeScript SDK 中,请求的类型定义如下:
typescript
// TypeScript SDK 中的 JSONRPCRequest 类型
{
jsonrpc: "2.0"; // 固定的协议版本标识
id: string | number; // 请求标识符,不可为 null
method: string; // 要调用的方法名
params?: { // 可选的参数对象
[key: string]: unknown;
};
}一个具体的 MCP 请求示例——客户端请求调用服务端的某个工具:
json
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "get_weather",
"arguments": {
"city": "Beijing"
}
}
}MCP 规范对 id 有比标准 JSON-RPC 更严格的要求:
id必须是字符串或整数,不可以是null(标准 JSON-RPC 允许null)。- 在同一个会话中,请求方不得重复使用已经用过的
id。
Python SDK 的类型定义同样体现了这些约束:
python
# Python SDK 中的 JSONRPCRequest 定义
class JSONRPCRequest(BaseModel):
jsonrpc: Literal["2.0"]
id: Annotated[int, Field(strict=True)] | str
method: str
params: dict[str, Any] | None = None3.2.2 Response(响应)
响应是对请求的回复,分为两种:成功响应(包含 result)和错误响应(包含 error)。
成功响应:
json
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{
"type": "text",
"text": "北京当前天气:晴,气温 22°C"
}
]
}
}错误响应:
json
{
"jsonrpc": "2.0",
"id": 3,
"error": {
"code": -32601,
"message": "Method not found",
"data": "tools/call is not supported"
}
}响应必须携带与原始请求相同的 id,这是请求-响应关联的唯一凭据。error 对象中的 code 必须是整数,message 是人类可读的错误描述,data 是可选的附加信息。
3.2.3 Notification(通知)
通知是一种"发完即忘"的消息——发送方不期望、也不应该收到任何回复。通知与请求的关键区别在于:没有 id 字段。
json
{
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed",
"params": {}
}在 MCP 中,通知被广泛用于状态变更的广播。比如当服务端的工具列表发生变化时,它会发送一个 notifications/tools/list_changed 通知,客户端收到后会重新获取工具列表。其他典型的通知还包括进度报告(notifications/progress)和取消操作(notifications/cancelled)。
通知的设计哲学是最终一致性而非强一致性。发送方不需要确认接收方是否收到了通知,也不需要等待处理结果。如果通知丢失,系统不会崩溃——最多只是状态更新慢一拍。
3.3 请求-响应关联与消息流
理解了三种消息类型之后,我们来看它们在实际通信中是如何协作的。下面这张时序图展示了一次典型的 MCP 交互过程,包含双向通信的场景:
这个流程清晰地展示了 MCP 双向通信的本质:
- 客户端发送了
id:1的请求。 - 服务端在处理过程中通过通知报告进度(通知没有
id,不期望回复)。 - 服务端发现需要额外信息,于是反向向客户端发送了
id:100的请求。客户端处理后返回id:100的响应。 - 服务端最终返回
id:1的响应,完成整个请求。
注意,id 的匹配是精确的:id:1 的响应对应 id:1 的请求,id:100 的响应对应 id:100 的请求。这种关联机制使得多个请求可以并发进行而互不干扰。
3.4 错误码体系
MCP 的错误码体系建立在 JSON-RPC 2.0 标准之上,同时引入了协议特定的扩展。
3.4.1 标准 JSON-RPC 错误码
根据 JSON-RPC 2.0 规范,以下错误码是预留的:
| 错误码 | 常量名 | 含义 |
|---|---|---|
| -32700 | PARSE_ERROR | 解析错误:收到的不是合法的 JSON |
| -32600 | INVALID_REQUEST | 无效请求:JSON 合法但不是有效的 JSON-RPC 请求 |
| -32601 | METHOD_NOT_FOUND | 方法未找到:请求的方法不存在 |
| -32602 | INVALID_PARAMS | 参数无效:方法参数不合法 |
| -32603 | INTERNAL_ERROR | 内部错误:服务端内部发生了错误 |
这些错误码在 TypeScript SDK 的 constants.ts 中定义:
typescript
// TypeScript SDK — types/constants.ts
export const PARSE_ERROR = -32_700;
export const INVALID_REQUEST = -32_600;
export const METHOD_NOT_FOUND = -32_601;
export const INVALID_PARAMS = -32_602;
export const INTERNAL_ERROR = -32_603;3.4.2 MCP 特定错误码
在 JSON-RPC 规范预留的 -32000 到 -32099 区间内,MCP 定义了自己的扩展错误码。TypeScript SDK 的 enums.ts 中通过 ProtocolErrorCode 枚举来管理这些错误码:
typescript
// TypeScript SDK — types/enums.ts
export enum ProtocolErrorCode {
// 标准 JSON-RPC 错误码
ParseError = -32_700,
InvalidRequest = -32_600,
MethodNotFound = -32_601,
InvalidParams = -32_602,
InternalError = -32_603,
// MCP 特定错误码
ResourceNotFound = -32_002,
UrlElicitationRequired = -32_042,
}Python SDK 在 types/jsonrpc.py 中定义了相同的常量,并额外包含两个 SDK 层面的错误码:
python
# Python SDK — types/jsonrpc.py
# MCP 特定错误码
URL_ELICITATION_REQUIRED = -32042
# SDK 错误码(非协议层面,用于 SDK 内部)
CONNECTION_CLOSED = -32000
REQUEST_TIMEOUT = -32001
# 标准 JSON-RPC 错误码
PARSE_ERROR = -32700
INVALID_REQUEST = -32600
METHOD_NOT_FOUND = -32601
INVALID_PARAMS = -32602
INTERNAL_ERROR = -326033.4.3 错误处理的工程实践
错误码不仅仅是数字——它们决定了接收方应该如何处理异常情况。下面这张图展示了错误在系统中的传播路径:
TypeScript SDK 中的 ProtocolError 类封装了这些错误码,并提供了一个工厂方法来根据错误码创建不同类型的错误实例:
typescript
// TypeScript SDK — types/errors.ts
export class ProtocolError extends Error {
constructor(
public readonly code: number,
message: string,
public readonly data?: unknown
) {
super(message);
}
static fromError(code: number, message: string, data?: unknown): ProtocolError {
if (code === ProtocolErrorCode.UrlElicitationRequired && data) {
const errorData = data as { elicitations?: unknown[] };
if (errorData.elicitations) {
return new UrlElicitationRequiredError(/*...*/);
}
}
return new ProtocolError(code, message, data);
}
}fromError 工厂方法的设计值得注意:它会检查错误码,如果是 UrlElicitationRequired,则创建一个专门的子类 UrlElicitationRequiredError,方便客户端进行类型化处理。这种模式在 Python SDK 中也有对应实现:
python
# Python SDK — shared/exceptions.py
class MCPError(Exception):
@classmethod
def from_jsonrpc_error(cls, error: JSONRPCError) -> MCPError:
return cls.from_error_data(error.error)
@classmethod
def from_error_data(cls, error: ErrorData) -> MCPError:
return cls(code=error.code, message=error.message, data=error.data)3.5 Protocol 类:消息路由的中枢
TypeScript SDK 的 Protocol 类是整个消息处理机制的核心。它实现了 JSON-RPC 协议的上层封装,将原始的消息收发转化为类型安全的请求-响应交互。
3.5.1 核心数据结构
Protocol 类内部维护了几个关键的映射表:
typescript
// Protocol 类的关键成员变量
private _requestMessageId = 0; // 自增的消息 ID
private _requestHandlers: Map<string, Handler>; // 方法名 → 请求处理器
private _notificationHandlers: Map<string, Handler>; // 方法名 → 通知处理器
private _responseHandlers: Map<number, Handler>; // 消息 ID → 响应回调
private _progressHandlers: Map<number, Callback>; // 消息 ID → 进度回调
private _timeoutInfo: Map<number, TimeoutInfo>; // 消息 ID → 超时信息这些映射表构成了一个完整的消息路由系统:
- 发送请求时,分配一个自增的
_requestMessageId,并在_responseHandlers中注册回调。 - 收到响应时,根据
id从_responseHandlers中取出回调并执行。 - 收到请求时,根据
method从_requestHandlers中取出处理器并执行。 - 收到通知时,根据
method从_notificationHandlers中取出处理器并执行。
3.5.2 消息分发机制
当 Transport 层收到一条消息时,Protocol 通过一系列类型守卫函数来判断消息类型并分发到对应的处理器:
typescript
// Protocol.connect() 中设置的消息回调
this._transport.onmessage = (message, extra) => {
if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
this._onresponse(message); // 响应消息 → 关联到原始请求
} else if (isJSONRPCRequest(message)) {
this._onrequest(message, extra); // 请求消息 → 查找并调用处理器
} else if (isJSONRPCNotification(message)) {
this._onnotification(message); // 通知消息 → 查找并调用处理器
} else {
this._onerror(new Error(`Unknown message type`));
}
};类型守卫函数定义在 types/guards.ts 中,使用 Zod schema 的 safeParse 来判断消息类型:
typescript
export const isJSONRPCRequest = (value: unknown): value is JSONRPCRequest =>
JSONRPCRequestSchema.safeParse(value).success;
export const isJSONRPCNotification = (value: unknown): value is JSONRPCNotification =>
JSONRPCNotificationSchema.safeParse(value).success;
export const isJSONRPCResultResponse = (value: unknown): value is JSONRPCResultResponse =>
JSONRPCResultResponseSchema.safeParse(value).success;判断的逻辑本质上很简单:有 id 且有 method 就是请求;没有 id 但有 method 就是通知;有 id 且有 result 或 error 就是响应。
3.5.3 请求-响应关联的完整生命周期
当客户端调用 protocol.request() 发送一个请求时,内部经历了以下步骤:
- 分配 ID:使用自增计数器
_requestMessageId++生成唯一的消息 ID。 - 注册回调:在
_responseHandlers中注册一个回调函数,等待响应到来时被调用。 - 设置超时:启动一个超时计时器,默认 60 秒(
DEFAULT_REQUEST_TIMEOUT_MSEC = 60_000)。 - 发送消息:通过
Transport发送 JSON-RPC 请求。 - 等待响应:返回一个 Promise,在收到匹配
id的响应后 resolve。
typescript
// 简化后的 request() 核心逻辑
protected _requestWithSchema<T>(request, resultSchema, options): Promise<T> {
return new Promise<T>((resolve, reject) => {
const messageId = this._requestMessageId++;
// 注册响应回调
this._responseHandlers.set(messageId, response => {
if (response instanceof Error) {
return reject(response);
}
const parseResult = parseSchema(resultSchema, response.result);
parseResult.success ? resolve(parseResult.data) : reject(parseResult.error);
});
// 构造 JSON-RPC 请求
const jsonrpcRequest = {
...request,
jsonrpc: "2.0",
id: messageId
};
// 设置超时
const timeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC;
this._setupTimeout(messageId, timeout, options?.maxTotalTimeout,
() => cancel(new SdkError(SdkErrorCode.RequestTimeout, "Request timed out")),
options?.resetTimeoutOnProgress ?? false
);
// 发送
this._transport.send(jsonrpcRequest).catch(reject);
});
}当对端响应到达时,_onresponse 方法完成关联:
typescript
private _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void {
const messageId = Number(response.id);
const handler = this._responseHandlers.get(messageId);
if (handler === undefined) {
this._onerror(new Error(`Unknown message ID: ${response.id}`));
return;
}
// 清理状态
this._responseHandlers.delete(messageId);
this._cleanupTimeout(messageId);
this._progressHandlers.delete(messageId);
// 分发响应
if (isJSONRPCResultResponse(response)) {
handler(response);
} else {
handler(ProtocolError.fromError(response.error.code, response.error.message));
}
}3.5.4 超时管理
MCP 的超时机制不是简单的"到时间就断",而是支持多种策略:
- 基础超时(
timeout):单次等待的最长时间,默认 60 秒。 - 进度重置(
resetTimeoutOnProgress):如果收到进度通知,重置超时计时器。这对于长时间运行但有持续反馈的操作非常有用。 - 最大总超时(
maxTotalTimeout):即使进度通知不断重置计时器,总等待时间也不能超过这个上限。
typescript
private _resetTimeout(messageId: number): boolean {
const info = this._timeoutInfo.get(messageId);
if (!info) return false;
// 检查是否超过了最大总超时
const totalElapsed = Date.now() - info.startTime;
if (info.maxTotalTimeout && totalElapsed >= info.maxTotalTimeout) {
throw new SdkError(SdkErrorCode.RequestTimeout,
"Maximum total timeout exceeded");
}
// 重置计时器
clearTimeout(info.timeoutId);
info.timeoutId = setTimeout(info.onTimeout, info.timeout);
return true;
}Python SDK 中的超时处理更加简洁,使用 anyio.fail_after 实现:
python
# Python SDK — BaseSession.send_request()
timeout = request_read_timeout_seconds or self._session_read_timeout_seconds
try:
with anyio.fail_after(timeout):
response_or_error = await response_stream_reader.receive()
except TimeoutError:
raise MCPError(code=REQUEST_TIMEOUT,
message=f"Timed out waiting for response. Waited {timeout}s.")3.6 通知去抖动
在实际使用中,一个操作可能会触发多次状态变更通知。例如,批量注册多个工具时,每次注册都可能触发 notifications/tools/list_changed。如果不做任何优化,客户端会在极短时间内收到大量重复的通知,每次都去重新获取工具列表,造成不必要的性能开销。
TypeScript SDK 的 Protocol 类通过 debouncedNotificationMethods 选项解决了这个问题:
typescript
const protocol = new Protocol({
debouncedNotificationMethods: ["notifications/tools/list_changed"]
});去抖动的实现原理是利用 JavaScript 的微任务队列(microtask queue):
typescript
async notification(notification, options): Promise<void> {
const debouncedMethods = this._options?.debouncedNotificationMethods ?? [];
const canDebounce = debouncedMethods.includes(notification.method)
&& !notification.params
&& !options?.relatedRequestId
&& !options?.relatedTask;
if (canDebounce) {
// 如果同类通知已在排队,直接跳过
if (this._pendingDebouncedNotifications.has(notification.method)) {
return;
}
// 标记为待发送
this._pendingDebouncedNotifications.add(notification.method);
// 在下一个微任务中发送
Promise.resolve().then(() => {
this._pendingDebouncedNotifications.delete(notification.method);
if (!this._transport) return;
this._transport.send(jsonrpcNotification, options)
.catch(error => this._onerror(error));
});
return;
}
// 非去抖动通知,立即发送
await this._transport.send(jsonrpcNotification, options);
}这个机制的精妙之处在于它使用了 Promise.resolve().then(...) 而不是 setTimeout。Promise.resolve() 创建的是一个微任务,它会在当前同步代码执行完毕后、下一个宏任务开始之前执行。这意味着在同一个事件循环 tick 中触发的所有同类通知都会被合并为一次发送。
需要注意的是,去抖动只适用于"简单"通知——即没有参数(!notification.params)、没有关联请求 ID、没有关联任务的通知。带有特定参数的通知不能被合并,因为参数内容可能不同。
3.7 双向通信的工程实现
MCP 的双向通信不仅仅是规范层面的概念,它在 SDK 中有具体的工程实现。Protocol 是一个抽象基类,客户端和服务端都继承自它:
Protocol (抽象基类)
├── Client(客户端实现)
└── Server(服务端实现)两者共享同一套消息路由机制。当服务端需要向客户端发送请求(如请求 LLM 采样)时,它使用的 API 与客户端向服务端发送请求完全相同——都是调用 Protocol.request() 方法。唯一的区别在于各自能够处理和发送的方法集合不同,这通过 assertCapabilityForMethod 等抽象方法来约束。
在请求处理器内部,服务端可以通过上下文对象反向与客户端通信:
typescript
// 服务端请求处理器中的反向通信
server.setRequestHandler("tools/call", async (request, ctx) => {
// ctx.mcpReq.send 发送反向请求给客户端
const samplingResult = await ctx.mcpReq.send({
method: "sampling/createMessage",
params: { messages: [...] }
});
// ctx.mcpReq.notify 发送通知给客户端
await ctx.mcpReq.notify({
method: "notifications/progress",
params: { progressToken: ctx.mcpReq._meta?.progressToken, progress: 0.5 }
});
return { content: [...] };
});ctx.mcpReq.send 和 ctx.mcpReq.notify 内部会自动关联当前正在处理的请求 ID,这对于某些传输层(如 Streamable HTTP)正确地将消息路由到对应的连接至关重要。
3.8 取消机制
MCP 支持请求取消,这对于可能长时间运行的 AI 操作(如复杂的代码分析或大规模数据检索)尤其重要。
取消通过一对机制协作实现:
发送方(取消请求的一方)发送一个 notifications/cancelled 通知:
json
{
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": {
"requestId": 3,
"reason": "User cancelled the operation"
}
}接收方(正在处理请求的一方)通过 AbortController 感知到取消信号:
typescript
// Protocol 中的取消处理
private async _oncancel(notification: CancelledNotification): Promise<void> {
const controller = this._requestHandlerAbortControllers.get(
notification.params.requestId
);
controller?.abort(notification.params.reason);
}请求处理器通过上下文中的 signal 属性感知取消:
typescript
server.setRequestHandler("tools/call", async (request, ctx) => {
// 检查是否已被取消
if (ctx.mcpReq.signal.aborted) {
throw new Error("Operation cancelled");
}
// 在长时间操作中定期检查
for (const item of largeDataSet) {
ctx.mcpReq.signal.throwIfAborted();
await processItem(item);
}
return result;
});取消是协作式的——发送取消通知只是一个请求,处理方有责任在适当的时机检查 signal.aborted 并停止工作。这与操作系统中信号机制的设计哲学一致:发送信号是即时的,但响应信号是异步的。
3.9 小结
JSON-RPC 2.0 之于 MCP,就如同 TCP 之于 HTTP——它提供了可靠的、结构化的消息交换基础,而上层协议在此之上构建语义。
本章的核心要点可以总结为:
三种消息类型各司其职:Request 用于需要回复的操作,Response 用于返回结果或错误,Notification 用于单向通知。区分它们的关键是
id字段的有无。双向通信是 MCP 的根本需求:客户端和服务端都可以发送请求和通知,这使得服务端能够在处理过程中向客户端请求 LLM 采样、用户确认等能力。
错误码体系分层设计:标准 JSON-RPC 错误码处理协议层面的问题,MCP 特定错误码(如
ResourceNotFound、UrlElicitationRequired)处理业务层面的问题。Protocol 类是消息路由的中枢:通过维护
_responseHandlers、_requestHandlers、_notificationHandlers三个映射表,实现了消息的精确分发和请求-响应关联。超时和取消机制确保系统的健壮性:支持基础超时、进度重置超时、最大总超时三种策略,以及基于
AbortController的协作式取消。通知去抖动是重要的性能优化:利用微任务队列合并同一事件循环 tick 中的重复通知,避免不必要的网络开销。
下一章我们将讨论 MCP 的传输层——JSON-RPC 消息如何在不同的通信管道(stdio、HTTP)上实际传输。