MCP 协议设计与实现
第9章 TypeScript Client 实现剖析
协议的上限由它的客户端实现决定:Client 是模型与工具世界之间那座桥。
本章要点
- 理解 Client 类如何继承 Protocol 基类,通过 connect() 完成初始化握手与能力协商
- 掌握 listTools、callTool、listResources、readResource 等核心 API 的实现细节
- 深入 OAuth 认证体系:AuthProvider 与 OAuthClientProvider 的双层设计
- 理解中间件链(Middleware Chain)的组合模式与实际应用
- 掌握 listChanged 通知处理器的防抖机制与自动刷新策略
- 理解 Transport 选择策略与重连逻辑
9.1 Client 类的整体架构
在前一章中,我们剖析了 Server 端的实现。现在让我们转向对称的另一面——Client 端。MCP TypeScript SDK 中的 Client 类位于 packages/client/src/client/client.ts,它是 AI 应用(Host)与 MCP Server 之间的桥梁。
Client 的核心职责可以归纳为三件事:
- 连接与握手——通过 Transport 连接 Server,完成
initialize/initialized握手,协商协议版本与能力 - 请求代理——将上层应用的调用(listTools、callTool 等)转化为 JSON-RPC 请求发送给 Server
- 事件处理——监听 Server 推送的通知(工具列表变更、资源变更等),驱动上层应用更新
classDiagram
class Protocol {
<<abstract>>
#transport: Transport
+connect(transport)
+close()
+request(method, params)
+notification(method, params)
+setRequestHandler(method, handler)
+setNotificationHandler(method, handler)
#assertCapabilityForMethod(method)*
#assertNotificationCapability(method)*
#assertRequestHandlerCapability(method)*
}
class Client {
-_serverCapabilities: ServerCapabilities
-_serverVersion: Implementation
-_negotiatedProtocolVersion: string
-_capabilities: ClientCapabilities
-_instructions: string
-_jsonSchemaValidator: JsonSchemaValidator
-_cachedToolOutputValidators: Map
+connect(transport, options)
+ping()
+listTools(params, options)
+callTool(params, options)
+listResources(params, options)
+readResource(params, options)
+subscribeResource(params, options)
+listPrompts(params, options)
+getPrompt(params, options)
+complete(params, options)
+getServerCapabilities()
+getServerVersion()
+getInstructions()
}
Protocol <|-- Client
Client --> Transport : uses
Client --> JsonSchemaValidator : validates output
构造函数:能力声明与验证器初始化
Client 的构造函数接收两个参数:客户端身份信息(Implementation)和可选配置(ClientOptions)。这里有几个值得注意的设计:
constructor(
private _clientInfo: Implementation,
options?: ClientOptions
) {
super({
...options,
tasks: extractTaskManagerOptions(options?.capabilities?.tasks)
});
this._capabilities = options?.capabilities
? { ...options.capabilities } : {};
this._jsonSchemaValidator = options?.jsonSchemaValidator
?? new DefaultJsonSchemaValidator();
this._enforceStrictCapabilities =
options?.enforceStrictCapabilities ?? false;
// Strip runtime-only fields from advertised capabilities
if (options?.capabilities?.tasks) {
const { taskStore, taskMessageQueue,
defaultTaskPollInterval, maxTaskQueueSize,
...wireCapabilities } = options.capabilities.tasks;
this._capabilities.tasks = wireCapabilities;
}
// Store list changed config for setup after connection
if (options?.listChanged) {
this._pendingListChangedConfig = options.listChanged;
}
}
第一个关键决策:运行时字段的剥离。tasks 能力中包含 taskStore、taskMessageQueue 等运行时配置,这些不应该在初始化握手时发送给 Server。SDK 通过解构赋值优雅地分离了"线上能力"(advertised capabilities)和"运行时配置"(runtime options)。
第二个关键决策:listChanged 配置的延迟设置。用户在构造时传入的 listChanged 处理器不会立即注册,而是存储在 _pendingListChangedConfig 中。为什么?因为 listChanged 处理器需要根据 Server 的能力来决定是否启用——只有 Server 声明了 tools.listChanged: true,注册工具变更监听才有意义。这个设置会在 connect() 完成初始化握手后执行。
9.2 连接与初始化握手
connect() 方法是 Client 生命周期的起点。它覆写了 Protocol 基类的 connect(),在建立传输层连接之后,自动执行 MCP 初始化握手(这是本专栏锚定的 2025-11-25 规范的形态;2026-07-28 版已去掉 initialize 握手与协议级会话,版本和能力改由每个请求的 _meta 携带,见该版 changelog 的 SEP-2575 / SEP-2567。pin 版 SDK 实现的最新协议仍是 2025-11-25):
sequenceDiagram
participant App as 上层应用
participant Client as Client
participant Transport as Transport
participant Server as MCP Server
App->>Client: connect(transport)
Client->>Transport: super.connect(transport)
Note over Transport: transport.start()<br/>(stdio 起子进程 / SSE 建流 /<br/>Streamable HTTP 此时不发请求)
alt 首次连接 (sessionId === undefined)
Client->>Server: initialize 请求
Note right of Client: protocolVersion<br/>capabilities<br/>clientInfo
Server-->>Client: InitializeResult
Note left of Server: protocolVersion<br/>capabilities<br/>serverInfo<br/>instructions
Client->>Client: 存储 serverCapabilities<br/>negotiatedProtocolVersion
Client->>Server: notifications/initialized
Client->>Client: _setupListChangedHandlers()
else 重连 (sessionId !== undefined)
Client->>Transport: setProtocolVersion(negotiated)
Note over Client: 跳过初始化握手<br/>恢复协议版本
end
App->>Client: 开始使用 API
让我们逐步分析这个流程中的关键逻辑:
首次连接:完整的初始化握手
// packages/client/src/client/client.ts:492-549(节选:省略了 result 为 undefined 时抛错的防御分支)
override async connect(transport: Transport,
options?: RequestOptions): Promise<void> {
await super.connect(transport);
// 重连检测:sessionId 已存在则跳过初始化
if (transport.sessionId !== undefined) {
if (this._negotiatedProtocolVersion !== undefined
&& transport.setProtocolVersion) {
transport.setProtocolVersion(
this._negotiatedProtocolVersion);
}
return;
}
try {
const result = await this._requestWithSchema(
{
method: 'initialize',
params: {
protocolVersion:
this._supportedProtocolVersions[0]
?? LATEST_PROTOCOL_VERSION,
capabilities: this._capabilities,
clientInfo: this._clientInfo
}
},
InitializeResultSchema,
options
);
// 协议版本校验
if (!this._supportedProtocolVersions
.includes(result.protocolVersion)) {
throw new Error(
`Server's protocol version is not supported: `
+ result.protocolVersion);
}
// 存储协商结果
this._serverCapabilities = result.capabilities;
this._serverVersion = result.serverInfo;
this._negotiatedProtocolVersion = result.protocolVersion;
// HTTP 传输必须在握手后把版本写进每个请求头
if (transport.setProtocolVersion) {
transport.setProtocolVersion(result.protocolVersion);
}
this._instructions = result.instructions;
// 通知 Server 初始化完成
await this.notification({
method: 'notifications/initialized'
});
// 设置 listChanged 处理器
if (this._pendingListChangedConfig) {
this._setupListChangedHandlers(
this._pendingListChangedConfig);
this._pendingListChangedConfig = undefined;
}
} catch (error) {
void this.close();
throw error;
}
}
这段代码有三个值得深思的设计:
协议版本协商的单向性。Client 发送自己支持的最高版本(_supportedProtocolVersions[0]),Server 返回它选择的版本。如果 Server 选择的版本不在 Client 的支持列表中,直接抛出错误。这是一个"Client 提议,Server 决策,Client 校验"的三步协商模式。
失败时的清理策略。catch 块中调用 this.close() 但使用 void 忽略其返回值。这确保了初始化失败时传输层被正确关闭,不会留下僵尸连接,同时避免 close() 自身的错误掩盖真正的初始化错误。
HTTP Transport 的协议版本传递。transport.setProtocolVersion 是一个可选方法,只有 HTTP 类传输实现(Streamable HTTP 与旧版 SSE,packages/client/src/client/streamableHttp.ts:741、packages/client/src/client/sse.ts:308;stdio 没有)。调用后,传输层会在每个后续请求的 HTTP 头中携带 mcp-protocol-version——规范要求 HTTP 客户端在后续请求上都带这个头,让 Server 按协商好的协议版本处理请求。
重连:跳过握手的快速路径
重连逻辑非常精简:当 transport.sessionId 已存在时(例如应用用原来的 sessionId 构造了新的 StreamableHTTPClientTransport,该类有 sessionId 选项),说明这是对已有会话的重连。此时只需恢复协议版本号,不需要重新握手。这个设计依赖一个前提——Server 端通过 sessionId 维护了会话状态,包括之前协商的能力和协议版本。
9.3 核心 API:工具、资源与提示词
Client 提供了一组高度一致的 API 来访问 Server 的三大原语——Tools、Resources 和 Prompts。这些 API 共享一个统一的实现模式。
能力检查:宽松模式与严格模式
每个 API 在发送请求前都会检查 Server 是否声明了对应能力。这里有一个重要的设计决策——宽松模式(默认)和严格模式的选择:
async listTools(params?: ListToolsRequest['params'],
options?: RequestOptions) {
if (!this._serverCapabilities?.tools
&& !this._enforceStrictCapabilities) {
// 宽松模式:Server 未声明 tools 能力时返回空列表
return { tools: [] };
}
// 严格模式下,assertCapabilityForMethod 会抛出异常
const result = await this._requestWithSchema(
{ method: 'tools/list', params },
ListToolsResultSchema, options);
this.cacheToolMetadata(result.tools);
return result;
}
listResources、listResourceTemplates 和 listPrompts 遵循相同的模式(只是不做工具元数据缓存),宽松模式下返回空列表前还会打一行 console.debug。宽松模式的设计意图是提升容错性:一个 Client 可能同时连接多个 Server,部分 Server 不支持 tools 是正常的。返回空列表比抛出异常更友好。
而 readResource、callTool、getPrompt、subscribeResource 这些"操作型"API 在方法体里一行能力检查都没有——它们直接 _requestWithSchema(...)。真正的检查发生在 Protocol._requestWithSchema() 内部(request() 也是转调它),而那一句是有条件的:
// packages/core/src/shared/protocol.ts:816-823(简化:源码用 try/catch 把异常转成 Promise 的 reject)
if (this._options?.enforceStrictCapabilities === true) {
this.assertCapabilityForMethod(request.method as RequestMethod);
}
也就是说,默认(宽松模式)下本地不拦截:listTools()、listPrompts()、listResources()、listResourceTemplates() 这类列表方法在 Server 没声明对应能力时直接返回空列表,其余请求原样发给 Server、由 Server 回 JSON-RPC 错误;只有显式打开 enforceStrictCapabilities: true,Client.assertCapabilityForMethod()(packages/client/src/client/client.ts:581-642)才会在本地先抛 SdkError(SdkErrorCode.CapabilityNotSupported, …)。(反方向上,Server 的 elicitInput() 和带 tools 的 createMessage() 例外,无论开关如何都会先在本地检查 Client 能力,见第 2 章。)
实跑(STDIO,pin 版 TS 低层 Server 只声明了 prompts):
| 调用 | 默认(宽松) | enforceStrictCapabilities: true |
|---|---|---|
listTools() |
返回 { tools: [] },不发请求 |
本地抛 SdkError,code CAPABILITY_NOT_SUPPORTED |
callTool() |
请求发出,Server 回 -32601 Method not found,以 ProtocolError 抛出 |
本地抛 SdkError,code CAPABILITY_NOT_SUPPORTED |
readResource() |
同 callTool(),-32601 |
本地抛 SdkError,code CAPABILITY_NOT_SUPPORTED |
listPrompts() |
正常返回 | 正常返回 |
所以两种模式的区别不只是"谁来报错、报得多早":对列表方法,宽松模式根本不报错,而是静默给出空列表。
callTool:结构化输出验证
callTool 是所有 API 中最复杂的,因为它涉及 outputSchema 验证和任务型工具的拦截:
// packages/client/src/client/client.ts:870-917(简化:错误信息缩短,省略了把校验器自身异常包成 ProtocolError 的 try/catch)
async callTool(params: CallToolRequest['params'],
options?: RequestOptions) {
// 拦截 required-task 工具
if (this.isToolTaskRequired(params.name)) {
throw new ProtocolError(
ProtocolErrorCode.InvalidRequest,
`Tool "${params.name}" requires task-based execution.`
);
}
const result = await this._requestWithSchema(
{ method: 'tools/call', params },
CallToolResultSchema, options);
// 获取缓存的输出验证器
const validator = this.getToolOutputValidator(params.name);
if (validator) {
// 有 outputSchema 的工具必须返回 structuredContent
if (!result.structuredContent && !result.isError) {
throw new ProtocolError(
ProtocolErrorCode.InvalidRequest,
`Tool has output schema but no structured content`
);
}
if (result.structuredContent) {
const validationResult =
validator(result.structuredContent);
if (!validationResult.valid) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Structured content does not match schema`
);
}
}
}
return result;
}
这个实现展现了三层防御:
-
任务拦截层:标记为
taskSupport: 'required'的工具不允许通过普通callTool调用,必须使用experimental.tasks.callToolStream()。这个检查基于listTools时缓存的元数据。 -
存在性校验层:如果工具声明了
outputSchema,则响应中 必须 包含structuredContent(除非是错误响应)。这防止了 Server 实现的遗漏。 -
Schema 验证层:使用预编译的 JSON Schema 验证器检查
structuredContent是否符合工具声明的输出格式。验证器在listTools时就已编译并缓存,避免了每次调用的重复编译开销。反过来,校验器只来自listTools的缓存:从没调用过listTools就直接callTool,Client 不做任何 outputSchema 校验(实跑:同一个返回{ n: "not-a-number" }的工具,listTools之前调用原样返回,之后调用抛ProtocolError-32602)。注意callTool里这几处ProtocolError都是 Client 在本地构造的,不是 Server 发来的错误响应。
工具元数据缓存
cacheToolMetadata 在每次 listTools 调用后执行,维护三个缓存:
private cacheToolMetadata(tools: Tool[]): void {
this._cachedToolOutputValidators.clear();
this._cachedKnownTaskTools.clear();
this._cachedRequiredTaskTools.clear();
for (const tool of tools) {
if (tool.outputSchema) {
const toolValidator =
this._jsonSchemaValidator
.getValidator(tool.outputSchema);
this._cachedToolOutputValidators
.set(tool.name, toolValidator);
}
const taskSupport = tool.execution?.taskSupport;
if (taskSupport === 'required'
|| taskSupport === 'optional') {
this._cachedKnownTaskTools.add(tool.name);
}
if (taskSupport === 'required') {
this._cachedRequiredTaskTools.add(tool.name);
}
}
}
这是一个典型的空间换时间策略:将 outputSchema 的编译和 taskSupport 的查找从 callTool 的热路径中移到 listTools 的冷路径中。在实际使用中,listTools 通常只调用一次(或在列表变更时重新调用),而 callTool 会被频繁调用。
资源订阅
资源相关的 API 包括列表查询、内容读取和变更订阅三个层次:
// 列出所有资源(宽松模式,分页支持)
async listResources(params?, options?) { ... }
// 列出资源 URI 模板
async listResourceTemplates(params?, options?) { ... }
// 读取资源内容
async readResource(params, options?) { ... }
// 订阅资源变更通知(需要 Server 声明 subscribe 能力)
async subscribeResource(params, options?) { ... }
// 取消订阅
async unsubscribeResource(params, options?) { ... }
assertCapabilityForMethod 里 resources/subscribe 比其他资源方法多一道检查——不仅要求 Server 声明 resources,还要求 resources.subscribe 为 true(packages/client/src/client/client.ts:607-612)。这是因为资源订阅是资源能力的一个子特性,并非所有支持资源的 Server 都实现了变更通知。同样地,这道检查也只在 enforceStrictCapabilities: true 时才跑。
9.4 listChanged 通知:防抖与自动刷新
当 Server 端的工具列表、资源列表或提示词列表发生变更时,它会发送 notifications/tools/list_changed 等通知。Client 需要优雅地处理这些通知,既要及时更新,又不能在高频变更时产生过多请求。
flowchart TB
subgraph 配置阶段
A[构造 Client] -->|listChanged 配置| B[存储到 _pendingListChangedConfig]
B --> C[connect 完成初始化]
C --> D{Server 是否声明<br/>listChanged 能力?}
D -->|是| E[_setupListChangedHandler<br/>注册通知处理器]
D -->|否| F[跳过,不注册]
end
subgraph 运行阶段
G[Server 发送<br/>list_changed 通知] --> H{配置了<br/>debounceMs?}
H -->|是| I[清除旧定时器<br/>设置新定时器]
I -->|等待 debounceMs| J{autoRefresh<br/>是否启用?}
H -->|否| J
J -->|是| K[调用 listTools/listResources<br/>获取最新列表]
K -->|成功| L["onChanged(null, items)"]
K -->|失败| M["onChanged(error, null)"]
J -->|否| N["onChanged(null, null)<br/>仅通知变更"]
end
style E fill:#2563eb,color:#fff,stroke:none
style K fill:#10b981,color:#333,stroke:none
style L fill:#7c3aed,color:#fff,stroke:none
防抖机制的实现
SDK 对每种列表类型(tools、prompts、resources)维护独立的防抖定时器:
private _setupListChangedHandler<T>(
listType: string,
notificationMethod: NotificationMethod,
options: ListChangedOptions<T>,
fetcher: () => Promise<T[]>
): void {
// 先用 Zod 校验 autoRefresh / debounceMs
const parseResult = parseSchema(ListChangedOptionsBaseSchema, options);
if (!parseResult.success) {
throw new Error(
`Invalid ${listType} listChanged options: `
+ parseResult.error.message);
}
if (typeof options.onChanged !== 'function') {
throw new TypeError(
`Invalid ${listType} listChanged options: `
+ 'onChanged must be a function');
}
const { autoRefresh, debounceMs } = parseResult.data;
const { onChanged } = options;
const refresh = async () => {
if (!autoRefresh) {
onChanged(null, null);
return;
}
try {
const items = await fetcher();
onChanged(null, items);
} catch (error) {
const newError = error instanceof Error
? error : new Error(String(error));
onChanged(newError, null);
}
};
const handler = () => {
if (debounceMs) {
const existingTimer =
this._listChangedDebounceTimers.get(listType);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(refresh, debounceMs);
this._listChangedDebounceTimers
.set(listType, timer);
} else {
refresh();
}
};
this.setNotificationHandler(notificationMethod, handler);
}
这里的 onChanged 回调采用了 Node.js 风格的 error-first 回调模式:onChanged(error, items)。当 autoRefresh 为 false 时,仅通知上层应用"列表已变更"(error 和 items 都为 null),由应用自行决定何时刷新。当 autoRefresh 为 true 时,SDK 自动调用对应的 listTools() / listResources() / listPrompts() 获取最新数据。两个选项都有默认值:autoRefresh 默认 true,debounceMs 默认 300,设为 0 即关闭防抖(packages/core/src/types/schemas.ts:1452、:1461)。
使用方式非常直观:
const client = new Client(
{ name: 'my-client', version: '1.0.0' },
{
listChanged: {
tools: {
autoRefresh: true,
debounceMs: 200,
onChanged: (error, tools) => {
if (error) {
console.error('刷新工具列表失败:', error);
return;
}
console.log('工具列表已更新:', tools);
}
}
}
}
);
实跑这段配置(STDIO):Server 在一次 tools/call 里同步连发 3 条 notifications/tools/list_changed,线上只跟出 1 次 tools/list,onChanged 也只触发 1 次。
9.5 Transport 选择与连接策略
Client 本身不绑定任何特定的传输层实现。它通过 connect(transport) 接受一个 Transport 接口的实例,由上层应用决定使用哪种传输方式。SDK 提供了三种内置传输:
| 传输类型 | 类名 | 适用场景 |
|---|---|---|
| Stdio | StdioClientTransport |
本地子进程(本机运行的 Server) |
| Streamable HTTP | StreamableHTTPClientTransport |
远程服务,生产部署 |
| SSE (旧版) | SSEClientTransport |
兼容旧版 Server |
SDK 在 Client.connect() 的文档示例里给出的连接策略是先尝试 Streamable HTTP,失败后降级到 SSE(packages/client/src/client/client.ts:473-490):
async function connectWithFallback(url: string) {
const baseUrl = new URL(url);
try {
// 优先使用现代 Streamable HTTP 传输
const client = new Client({
name: 'my-client', version: '1.0.0' });
const transport =
new StreamableHTTPClientTransport(baseUrl);
await client.connect(transport);
return { client, transport };
} catch {
// 降级到旧版 SSE 传输
const client = new Client({
name: 'my-client', version: '1.0.0' });
const transport = new SSEClientTransport(baseUrl);
await client.connect(transport);
return { client, transport };
}
}
注意这里的一个微妙点:降级时需要创建新的 Client 实例,而不是复用旧的。这是因为 connect() 失败时以 void this.close() 异步关闭旧传输,旧传输的 onclose 稍后才触发,而 Protocol._onclose() 会把 Client 当前的 _transport 置空、让所有在途请求以 SdkError(ConnectionClosed) 失败——如果此时同一个 Client 已经接上了新传输,新连接会被一并清掉。实跑:connect() 失败后立即用同一个 Client 连新传输,initialize 以 SdkError(code CONNECTION_CLOSED)失败。
重连逻辑的设计意图
回顾 connect() 中的重连检测:
if (transport.sessionId !== undefined) {
if (this._negotiatedProtocolVersion !== undefined
&& transport.setProtocolVersion) {
transport.setProtocolVersion(
this._negotiatedProtocolVersion);
}
return;
}
这段代码揭示了 MCP 的重连哲学:会话恢复,而非重新建立。网络中断后,应用用之前的 sessionId 重建 HTTP 传输(getNegotiatedProtocolVersion() 的注释也提示,手工重建时要把协商版本一并交给新传输)。Client 检测到 sessionId 已存在,只需将协议版本号同步到新的传输层实例,即可恢复通信。Server 端通过 sessionId 识别出这是同一个会话,保留之前的上下文——前提是 Server 还保留着这个会话。这是 2025-11-25 的会话模型;2026-07-28 版已移除协议级会话与 Mcp-Session-Id(SEP-2567)。
9.6 请求处理器:Server 发起的请求
MCP 协议不是单向的——Server 也可以向 Client 发起请求(这是 2025-11-25 的形态;2026-07-28 版把 Sampling / Elicitation / Roots 改成了 Multi Round-Trip Requests:Server 在结果里返回 input_required,Client 带着答复重试原请求,见该版 changelog 的 SEP-2322)。Client 通过 setRequestHandler 注册处理器来响应这些反向请求。注册前必须在构造参数里声明对应能力,否则 setRequestHandler 当场抛 SdkError(code CAPABILITY_NOT_SUPPORTED,实跑):
const client = new Client(
{ name: 'my-client', version: '1.0.0' },
{ capabilities: { sampling: {}, elicitation: {} } }
);
// 处理 Sampling 请求(Server 请求 Client 调用 LLM)
client.setRequestHandler(
'sampling/createMessage',
async (request) => {
return {
model: 'claude-3-opus',
role: 'assistant',
content: {
type: 'text',
text: 'Response from the model'
}
};
}
);
// 处理 Elicitation 请求(Server 请求用户输入)
client.setRequestHandler(
'elicitation/create',
async (request) => {
return {
action: 'accept',
content: { name: 'user-input-value' }
};
}
);
roots/list 的处理器注册方式相同(需要声明 roots 能力),它不经过下面 Client 这层额外包装,直接交给基类。
setRequestHandler 覆写了基类的同名方法,对 sampling/createMessage 和 elicitation/create 两个方法添加了额外的验证包装:
flowchart LR
subgraph "setRequestHandler 包装逻辑"
A[Server 请求到达] --> B{请求方法?}
B -->|sampling/createMessage| C[验证请求 Schema]
B -->|elicitation/create| D[验证请求 Schema<br/>检查模式支持]
B -->|其他| E[直接调用 handler]
C --> F[调用用户 handler]
D --> F
F --> G{是否 task 请求?}
G -->|是| H[验证 CreateTaskResult]
G -->|否| I[验证对应 Result Schema]
H --> J[返回结果]
I --> J
end
style C fill:#f59e0b,color:#333,stroke:none
style D fill:#f59e0b,color:#333,stroke:none
style H fill:#2563eb,color:#fff,stroke:none
style I fill:#2563eb,color:#fff,stroke:none
这个包装层做了三件事:
- 入参验证——用 Zod Schema 验证 Server 发来的请求格式
- 能力检查——对 elicitation 请求,检查 Client 是否声明了对应的模式支持(form/url)
- 出参验证——对用户 handler 返回的结果进行 Schema 验证,防止返回不合规的响应
特别值得注意的是 Elicitation 的 applyDefaults 机制。当 Client 声明了 elicitation.form.applyDefaults: true,且用户接受了 elicitation 请求时,SDK 会自动将 Schema 中声明的默认值填充到用户提交的数据中:
if (params.mode === 'form'
&& validatedResult.action === 'accept'
&& validatedResult.content
&& requestedSchema
&& this._capabilities.elicitation?.form?.applyDefaults) {
try {
applyElicitationDefaults(
requestedSchema, validatedResult.content);
} catch {
// 默认值填充失败时静默忽略
}
}
这个自动填充逻辑递归处理嵌套对象和 anyOf / oneOf 组合 Schema,是一个贴心的开发者体验优化。实跑:Server 的 requestedSchema 里 lang 带 default: 'zh',Client 处理器只返回 { name: 'yyt' },线上回给 Server 的是 { name: 'yyt', lang: 'zh' }。
9.7 OAuth 认证体系
MCP 的认证设计采用了 双层抽象 的模式,兼顾简单场景和复杂场景。
AuthProvider:最小认证接口
最简单的认证只需要提供一个 token:
interface AuthProvider {
token(): Promise<string | undefined>;
onUnauthorized?(ctx: UnauthorizedContext): Promise<void>;
}
这个接口足以覆盖 API Key、Gateway Token 等场景:
const authProvider: AuthProvider = {
token: async () => process.env.API_KEY
};
OAuthClientProvider:完整 OAuth 流程
对于需要用户授权的场景,SDK 提供了完整的 OAuthClientProvider 接口,涵盖 OAuth 2.1 的完整生命周期:
flowchart TB
subgraph "auth() 编排流程"
A[开始认证] --> B[检查缓存的 Discovery State]
B -->|有缓存| C[恢复 authorizationServerUrl<br/>resourceMetadata]
B -->|无缓存| D["RFC 9728 发现<br/>discoverOAuthServerInfo()"]
C --> E[确定 scope]
D --> E
E --> F{是否有<br/>clientInformation?}
F -->|否| G{Server 支持<br/>URL-based Client ID?}
G -->|是| H[使用 clientMetadataUrl<br/>作为 client_id]
G -->|否| I["动态注册<br/>registerClient()"]
F -->|是| J{是否有<br/>authorizationCode?}
H --> J
I --> J
J -->|"是(或无 redirectUrl 的非交互流程)"| K["获取令牌<br/>fetchToken()"]
J -->|否| L{是否有<br/>refresh_token?}
L -->|是| M["刷新令牌<br/>refreshAuthorization()"]
L -->|否| N["启动授权流程<br/>startAuthorization()"]
K --> O[保存 tokens<br/>返回 AUTHORIZED]
M -->|成功| O
M -->|"失败(非 OAuthError 或 server_error)"| N
N --> P[重定向用户<br/>返回 REDIRECT]
end
style D fill:#2563eb,color:#fff,stroke:none
style K fill:#10b981,color:#333,stroke:none
style M fill:#f59e0b,color:#333,stroke:none
style N fill:#db2777,color:#fff,stroke:none
auth() 函数是整个 OAuth 流程的编排器。它的错误恢复策略值得研究——外层 auth() 捕获特定的 OAuth 错误码并进行凭据失效后重试:
// packages/client/src/client/auth.ts:540-570(签名在源码里是多行带类型)
export async function auth(provider, options): Promise<AuthResult> {
try {
return await authInternal(provider, options);
} catch (error) {
if (error instanceof OAuthError) {
if (error.code === OAuthErrorCode.InvalidClient
|| error.code === OAuthErrorCode.UnauthorizedClient) {
// 客户端凭据无效,清除所有凭据后重试
await provider.invalidateCredentials?.('all');
return await authInternal(provider, options);
} else if (error.code === OAuthErrorCode.InvalidGrant) {
// 授权无效(如 refresh token 过期),
// 仅清除 tokens 后重试
await provider.invalidateCredentials?.('tokens');
return await authInternal(provider, options);
}
}
throw error;
}
}
这个设计的精妙之处在于分级失效:InvalidClient 清除一切(凭据可能已被吊销),InvalidGrant 只清除 tokens(客户端本身仍然有效,只是需要重新授权)。
客户端认证方法选择
SDK 实现了 OAuth 2.1 的三种客户端认证方法,并通过 selectClientAuthMethod 自动选择最佳方案:
// packages/client/src/client/auth.ts:400-436(节选,第一个 if 省略了对该字段非空的判断)
export function selectClientAuthMethod(
clientInformation: OAuthClientInformationMixed,
supportedMethods: string[]
): ClientAuthMethod {
const hasClientSecret =
clientInformation.client_secret !== undefined;
// 优先使用 DCR 返回的方法
if ('token_endpoint_auth_method' in clientInformation
&& isClientAuthMethod(
clientInformation.token_endpoint_auth_method)
&& (supportedMethods.length === 0
|| supportedMethods.includes(
clientInformation.token_endpoint_auth_method))) {
return clientInformation.token_endpoint_auth_method;
}
// 服务器元数据没给 token_endpoint_auth_methods_supported 时,
// 按 RFC 8414 §2 / RFC 6749 §2.3.1 默认 client_secret_basic
if (supportedMethods.length === 0) {
return hasClientSecret ? 'client_secret_basic' : 'none';
}
// 按安全级别降序选择
// client_secret_basic > client_secret_post > none
if (hasClientSecret
&& supportedMethods.includes('client_secret_basic')) {
return 'client_secret_basic';
}
if (hasClientSecret
&& supportedMethods.includes('client_secret_post')) {
return 'client_secret_post';
}
if (supportedMethods.includes('none')) {
return 'none';
}
return hasClientSecret ? 'client_secret_post' : 'none';
}
选择优先级:DCR 指定方法 > client_secret_basic > client_secret_post > none;若服务器元数据整个省略了 token_endpoint_auth_methods_supported(supportedMethods 为空数组),则直接按 RFC 8414 §2 的默认值取 client_secret_basic(无 secret 时取 none)。最后的兜底分支是 hasClientSecret ? 'client_secret_post' : 'none'。这确保了在安全性和兼容性之间取得最佳平衡。
双层适配
Transport 层只需要 AuthProvider 的最小接口,但用户可能传入完整的 OAuthClientProvider。SDK 通过 adaptOAuthProvider 进行适配:
export function adaptOAuthProvider(
provider: OAuthClientProvider
): AuthProvider {
return {
token: async () => {
const tokens = await provider.tokens();
return tokens?.access_token;
},
onUnauthorized: async ctx =>
handleOAuthUnauthorized(provider, ctx)
};
}
isOAuthClientProvider 类型守卫用于在运行时区分两种 Provider:
export function isOAuthClientProvider(
provider: AuthProvider | OAuthClientProvider | undefined
): provider is OAuthClientProvider {
if (provider == null) return false;
const p = provider as OAuthClientProvider;
return typeof p.tokens === 'function'
&& typeof p.clientInformation === 'function';
}
9.8 中间件链:可组合的 Fetch 增强
middleware.ts 提供了一个优雅的中间件系统,用于增强 fetch 函数的行为。它的形态与 Express/Koa 的中间件模式相同:每层包住下一层。
中间件类型定义
type Middleware = (next: FetchLike) => FetchLike;
每个中间件接收一个 next 函数(下一层的 fetch),返回一个增强后的 fetch。多个中间件通过 applyMiddlewares 组合:
export const applyMiddlewares =
(...middleware: Middleware[]): Middleware => {
return next => {
let handler = next;
for (const mw of middleware) {
handler = mw(handler);
}
return handler;
};
};
注意顺序:循环里后包的在外层,所以参数列表里越靠后的中间件越先拿到请求。
内置中间件
withOAuth——自动添加 Authorization 头,处理 401 响应:
const withOAuth = (provider, baseUrl?): Middleware =>
next => async (input, init) => {
const makeRequest = async () => {
const headers = new Headers(init?.headers);
const tokens = await provider.tokens();
if (tokens) {
headers.set('Authorization',
`Bearer ${tokens.access_token}`);
}
return next(input, { ...init, headers });
};
let response = await makeRequest();
if (response.status === 401) {
// 尝试重新认证
const result = await auth(provider, { ... });
if (result === 'AUTHORIZED') {
response = await makeRequest(); // 重试
}
// 简化:源码里 result 为 REDIRECT、或重试后仍是 401,
// 都会抛 UnauthorizedError
// (packages/client/src/client/middleware.ts:70-91)
}
return response;
};
withLogging——请求日志记录,支持自定义日志函数和状态级别过滤:
const enhancedFetch = applyMiddlewares(
withOAuth(oauthProvider, 'https://api.example.com'),
withLogging({ statusLevel: 400 })
)(fetch);
// 使用增强后的 fetch
const response = await enhancedFetch('https://api.example.com/data');
createMiddleware——简化自定义中间件的创建:
const customAuth = createMiddleware(
async (next, input, init) => {
const headers = new Headers(init?.headers);
headers.set('X-Custom-Auth', 'my-token');
return next(input, { ...init, headers });
}
);
MCP Transport 不会自动套用这些中间件(StreamableHTTPClientTransport 通过 authProvider 选项内置了 OAuth 处理),但它的 fetch 选项可以接收经中间件增强过的 fetch;中间件更大的用处是让通用 HTTP 请求也能复用 MCP 的 OAuth 基础设施。例如,一个应用可能需要拿为 MCP Server 取得的 token(由授权服务器签发)去访问其他 API 端点——这时中间件链就派上了用场。
9.9 能力断言体系
Client 实现了一套完整的能力断言机制;其中 assertCapabilityForMethod 只在 enforceStrictCapabilities: true 时才被调用(§9.3),开启后请求不会发送到不支持对应功能的 Server:
protected assertCapabilityForMethod(method: RequestMethod): void {
switch (method as ClientRequest['method']) {
case 'tools/call':
case 'tools/list':
if (!this._serverCapabilities?.tools) {
throw new SdkError(
SdkErrorCode.CapabilityNotSupported,
`Server does not support tools`);
}
break;
case 'resources/subscribe':
if (!this._serverCapabilities?.resources) {
throw new SdkError(...);
}
// 订阅需要额外检查 subscribe 子能力
if (!this._serverCapabilities.resources.subscribe) {
throw new SdkError(...);
}
break;
// ... 其他方法
}
}
能力断言分为三个维度:
| 断言方法 | 检查方向 | 用途 |
|---|---|---|
assertCapabilityForMethod |
Client -> Server | 确保 Server 支持该请求 |
assertNotificationCapability |
Client -> Server | 确保 Client 有权发送该通知 |
assertRequestHandlerCapability |
Server -> Client | 确保 Client 声明了处理该请求的能力 |
后两者不受开关影响:assertNotificationCapability 在每次发通知时检查(packages/core/src/shared/protocol.ts:953),assertRequestHandlerCapability 在注册处理器时检查(packages/core/src/shared/protocol.ts:1012)。这三维断言形成了一个双向能力检查网:严格模式下 Client 不会向不支持的 Server 发请求,而无论哪种模式,Client 都不会在没有声明能力的情况下注册 Server 反向请求的处理器。
9.10 主线小结
本章从源码层面剖析了 MCP TypeScript Client 的完整实现。让我们回顾核心设计决策:
继承与组合的平衡。Client 通过继承 Protocol 获得 JSON-RPC 通信能力,通过组合 Transport 获得传输灵活性,通过组合 JsonSchemaValidator 获得输出验证能力。这种混合策略在保持代码复用的同时,避免了深层继承的脆弱性。
宽松与严格的弹性。enforceStrictCapabilities 参数让 Client 可以根据部署场景在容错性和安全性之间选择。默认的宽松模式适合多 Server 环境,严格模式适合受控的生产环境。
缓存与延迟初始化。工具元数据在 listTools 时预编译并缓存,listChanged 处理器在初始化握手完成后才注册。这些延迟策略确保了信息在正确的时机可用。
双层认证抽象。简单场景用 AuthProvider(一个 token() 方法),复杂场景用 OAuthClientProvider(完整 OAuth 流程)。通过适配器模式统一两种接口,Transport 层无需关心认证的具体实现。
理解了 Client 的内部机制,我们就能更好地在实际项目中使用它——无论是构建 AI Agent、IDE 插件还是其他需要与 MCP Server 交互的应用。在后续章节中,我们将看到 Python SDK 中相似但不同的 Client 实现,以及各种传输层的具体实现细节。
以下为源码实证附录。 到上一节为止是本章主线,读完已可动手。从这里开始的各节是针对TypeScript Client 各模块的具体实现的独立深挖,彼此不构成阅读顺序,按需查阅即可。
9.11 client 模块完整源码地图——8 个实现文件 5469 行
打开 packages/client/src/client/——目录共 13 个文件 5908 行,其中 5 个 .examples.ts 示例占 439 行;下表是 8 个实现文件,合计 5469 行:
| 文件 | 行 | 职责 |
|---|---|---|
client.ts |
1068 | Client 类本身 |
auth.ts |
1745 | OAuth 2.1 完整实现(RFC 8414 / 9728 / 7591) |
authExtensions.ts |
702 | 面向机器对机器场景的现成 OAuthClientProvider 实现:ClientCredentialsProvider / PrivateKeyJwtProvider / StaticPrivateKeyJwtProvider / CrossAppAccessProvider(PKCE 与 discovery 都在 auth.ts 里,这里的四个 Provider 反而把 codeVerifier() 直接抛异常) |
middleware.ts |
319 | 中间件框架 + withOAuth / withLogging |
crossAppAccess.ts |
303 | 跨应用访问(企业 SSO 场景) |
streamableHttp.ts |
761 | Streamable HTTP 传输 |
sse.ts |
311 | 旧版 SSE 传输 |
stdio.ts |
260 | 本地子进程传输 |
三个观察——
- auth.ts 1745 行——比 Client 主体 1068 行还大——说明 OAuth 才是 MCP Client 最复杂的部分
- 三种 transport 总计 1332 行——Streamable HTTP 一家就占 761 行,stdio 260、SSE 311——分量明显向 Streamable HTTP 倾斜;SSE 是为兼容旧版 Server 保留的遗留传输
- crossAppAccess 303 行(外加
authExtensions.ts里的CrossAppAccessProvider)——表明 MCP SDK 在企业集成场景下有专门投入
这个目录是任何想"自己写个 Agent 框架"的人"值得抄一遍"的样板——模块边界清晰、职责单一、每个文件都能独立读完。
9.12 _listChangedDebounceTimers 的 Map 为什么必要
packages/client/src/client/client.ts:215 定义:
private _listChangedDebounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
每种 listType 独立 timer——不是单一 debounce——因为:
- Server 在同一时刻可能同时发多个 listChanged(tools / prompts / resources)
- 用单一 timer 的话——tools 的 debounce 会被 prompts 的 reset 覆盖——这一轮 tools 的刷新就被吞掉了
- 独立 Map per-type——三种 debounce 并行、互不干扰
这种"按维度分 timer"的模式——在前端框架的调度器里也能看到(《React 19 内核探秘》专栏、《Vue 3 设计与实现》专栏各有调度相关章节)——同一模式、不同语境。
9.13 _setupListChangedHandlers 的三重 guard
packages/client/src/client/client.ts:258-279——注册 listChanged handler 的三重守卫:
if (config.tools && this._serverCapabilities?.tools?.listChanged) {
this._setupListChangedHandler(...);
}
三重条件都要满足——
- 用户配置了
config.tools——不想要 auto-refresh 就别塞配置 - Server 声明了
tools能力——Server 根本没 tools 就没意义 - Server 声明了
tools.listChanged: true——Server 不打算发通知、注册了也白搭
三条 AND——任意一条不满足就"silently skip"(注释明确写了)——不报错、不警告。
为什么不报错——
- 用户可能同时连接多个 Server、部分支持 / 部分不支持
- "部分不支持" 是正常多态场景、不是错误
- 静默跳过 = 容错 + 减少噪音
本章§9.3 讲的"宽松模式" 就是同一哲学——MCP SDK 整体偏容错、不偏严格——因为 AI 应用对接 Server 生态天然异构。
9.14 invalidateCredentials 的 5 种 scope 语义
本章§9.7 提到 invalidateCredentials 的两种 scope——实际源码定义 5 种(packages/client/src/client/auth.ts:246):
invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void | Promise<void>;
这是 OAuthClientProvider 上的可选方法,由 Provider 实现方决定怎么清。SDK 自己只在 auth() 里调用了 'all' 和 'tokens' 两种(packages/client/src/client/auth.ts:556、:559);其余三种留给实现方按需调用(源码注释只点名了一处:重复认证失败时可用 'discovery' 或 'all' 清掉缓存的发现结果)。下表"何时用"一列是按字段含义给出的建议。
5 种 scope 的语义——
| scope | 清除什么 | 何时用 |
|---|---|---|
all |
所有(client + tokens + verifier + discovery) | 客户端凭据被吊销(InvalidClient) |
client |
只清 client registration(client_id/client_secret) | DCR 注册失败要重来 |
tokens |
只清 access + refresh token | token 过期 / InvalidGrant |
verifier |
只清 PKCE code_verifier | 授权流程中断要重启 |
discovery |
只清 discovery metadata 缓存 | Server 端点变更 |
为什么要这么细——
discovery缓存失效——不动 token、避免用户重新登录verifier失效——只影响当前在进行的授权、不影响已有 sessiontokens——最常见场景、保留 client registration 省一次 DCR
细粒度 invalidation 是 OAuth 工程化的"效率源泉"——粗粒度清理会让用户反复登录、体验极差。
9.15 selectClientAuthMethod 的四条决策路径
本章§9.7 给了简化版——真实源码(packages/client/src/client/auth.ts:400-436)一共四条路径:
- 路径 1:DCR 指定——
token_endpoint_auth_method字段直接拿——最高优先级 - 路径 2:
supportedMethods为空——服务器元数据整个没给token_endpoint_auth_methods_supported——按 RFC 8414 §2 / RFC 6749 §2.3.1 默认client_secret_basic(无 secret 则none) - 路径 3:按能力降序——
client_secret_basic>client_secret_post>none - 路径 4:fallback——如果 Server 声明的方法里一个都对不上——用 client_secret 就 post、没有就 none——让流程能继续、避免死锁
第 2、4 条是细节——RFC 8414 里 token_endpoint_auth_methods_supported 本就是可选字段——一些 Server 根本没这个字段——Client 不能因此放弃。第 2 条正是按 RFC 8414 的默认值走。
第 4 条 fallback 是 MCP SDK "实用主义"的一个例证——Server 声明的方法一个都对不上时,SDK 退回 client_secret_post / none 继续尝试,而不是直接失败——工程要容错。
9.16 discoverOAuthServerInfo 的 RFC 双轨
packages/client/src/client/auth.ts:1317 的 discoverOAuthServerInfo 把两套发现机制串联起来:
- RFC 9728 (2025-04)——Protected Resource Metadata——MCP Server 发布资源元数据(可经
WWW-Authenticate的resource_metadata参数指向它),其中authorization_servers列出授权服务器。这一步回答"找谁授权" - RFC 8414 (2018)——Authorization Server Metadata——到授权服务器的
/.well-known/oauth-authorization-server(以及 OIDC 的openid-configuration)拉元数据。这一步回答"授权服务器的端点在哪"
SDK 先走 9728 找授权服务器;9728 拿不到时(网络错误除外,那种直接抛出),退回旧版 MCP 规范的做法——把 MCP Server 自己的根 URL 当作授权服务器;两种情况最后都用 8414 / OIDC 拉授权服务器元数据——兼容新老 Server。
为什么 MCP 要选 9728——
- 9728 明确区分 resource server 和 authorization server——Agent 场景下 MCP Server 是"resource server"、OAuth 通常由单独 identity provider 做
- 旧版 MCP 规范(2025-03-26)默认 "MCP Server 就是授权服务器"——企业 SSO 场景不适用
这一条双轨——让 MCP SDK 既能对接 Okta / Auth0 这类新平台、也能对接公司内部用了十年的老 OAuth 服务——兼容性是生态的前提。
9.17 crossAppAccess.ts 303 行——企业级 SSO 专用
crossAppAccess.ts 303 行——一般用户用不到、但企业场景必需。
典型场景——
- 员工在公司内用 Claude Desktop
- Claude Desktop 连接内部 MCP Server(GitHub / Jira / Confluence 等)
- 每个 MCP Server 需要独立 OAuth——员工登录 10 次——灾难
- 企业用 IdP(Okta 等)统一身份——登一次、所有 Server 自动通过
crossAppAccess.ts 实现的机制(文件头注明对应 Enterprise Managed Authorization 规范,SEP-990)——
- Client 拿用户在 IdP 登录得到的 ID Token,向 IdP 做 RFC 8693 Token Exchange,换出一个 JWT Authorization Grant(ID-JAG)——
requestJwtAuthorizationGrant() - 再拿这个 ID-JAG 到 MCP Server 的授权服务器走 RFC 7523 JWT Bearer 授权,换出访问该 Server 的 access token(
grant_type为urn:ietf:params:oauth:grant-type:jwt-bearer)——exchangeJwtAuthGrant() - 无需每个 Server 独立登录
这是"多 Server Agent 生态"的关键基建——没它、企业级 MCP 部署几乎不可能。
9.18 Streamable HTTP 的 SSE 事件回放
streamableHttp.ts 761 行里有一段事件回放逻辑——Client 因网络抖动断连、重连时发 Last-Event-ID 头、Server 回放该 ID 之后的所有事件——断连窗口内的消息不丢。
对比 Server 侧(本专栏第 20 章§20.32 讨论的 EventStore interface)——两端"互为镜像"——Server 存储事件、Client 带 Last-Event-ID 请求重放——SSE 协议的 resumability 机制。
SDK 把最后收到的 event ID 通过 onresumptiontoken 回调交给调用方(packages/client/src/client/streamableHttp.ts:407),重连时作为 resumptionToken 传回、写进 last-event-id 请求头(packages/client/src/client/streamableHttp.ts:246)。另一个入口是 packages/client/src/client/streamableHttp.ts:755 定义的 resumeStream(lastEventId),直接用 Last-Event-ID 打开 GET 流补收漏掉的事件。
这是 2025-11-25 的机制;2026-07-28 版已从 Streamable HTTP 中移除 SSE 流的可恢复性与 Last-Event-ID,断掉的请求要以新 ID 重发(见该版 changelog)。
工程要点——
- Client 要自己保存最后收到的 event ID(想跨进程重启恢复,就不能只放内存)
- 重连时间窗口内 Server 也要保留事件——保留多久由
EventStore的实现方自己定:SDK 的packages/里只导出EventStore接口,不带任何内置实现(唯一的参考实现是examples/server/src/inMemoryEventStore.ts),因此也没有"官方默认过期时长"这回事 - Event ID 必须能唯一定位到某条流上的某个位置——否则 Server 无法断点续传
这呼应本专栏第 13 章讨论的 Streamable HTTP "可恢复性"——传输层的事件回放,与第 20 章 §20.32 的 EventStore 是同一件事的两端。
9.19 客户端的"Happy-Path 三行"示例
把本章所有内容压缩到最简——一个 working client 的三行:
const client = new Client({ name: 'my-agent', version: '1.0.0' })
await client.connect(new StdioClientTransport({ command: './my-server' }))
const tools = (await client.listTools()).tools
Behind these 3 lines——
Client构造 → 继承 Protocol、初始化 jsonSchemaValidator、暂存 listChanged configconnect→ StdioClientTransport 启动子进程 → initialize 请求 → 能力协商 → 存 serverCapabilities → notifications/initialized → setup listChanged handlerslistTools→ 能力检查 → JSON-RPC 请求 → 响应 schema 验证 → cacheToolMetadata(outputSchema 预编译 + taskSupport 标记)
3 行代码背后是上面这一整串内部逻辑——好 SDK 的特征就是"用起来简单、底下工程扎实"。
9.20 Python vs TypeScript SDK 的取舍差异
MCP 还有一个 Python SDK(modelcontextprotocol/python-sdk)——两个 SDK 体现不同的社区偏好:
| 维度 | TypeScript SDK | Python SDK |
|---|---|---|
| 典型用户(主观归纳) | 桌面 / IDE 类宿主 | AI 研究员 / 数据科学 |
| 风格 | OOP + 继承 | 更多装饰器 + 函数式 |
| 类型系统 | Zod runtime + TS 静态 | Pydantic runtime + typing |
| Async 模型 | Promise / async generator | asyncio / async context manager |
| OAuth 体量 | client/auth.ts 1745 行 + authExtensions.ts 702 行 |
mcp/client/auth/ 6 个文件共 1500 行(主要是 oauth2.py 646 + utils.py 339 + extensions/client_credentials.py 485) |
| Streamable HTTP 客户端 | streamableHttp.ts 761 行 |
streamable_http.py 588 行 |
| WebSocket 传输 | 已在 2.0 移除(changeset remove-websocket-transport:WebSocket 不是规范定义的传输) |
仍保留 mcp/client/websocket.py |
体量上 TS 侧 OAuth 相关代码(2447 行)确实比 Python 侧(1500 行)大,但差距远没有"完整 vs 简化"那么悬殊——两边都实现了 RFC 8414 / 9728 / 7591 的发现与注册流程,TS 多出来的部分主要在 authExtensions.ts 的可插拔认证方法与跨应用访问上。
倾向上,TypeScript SDK 更"企业级"——面向"部署到用户机器上的长期运行 Agent";Python SDK 更"研究友好"——面向"快速验证 Agent 原型"。(这一条是主观归纳,不是实测结论。)
选择原则——"用户终端" → TS,"后台 batch / 研究" → Python——两者生成的 Server 互操作、不锁定。
这呼应本专栏第 11 章 Python Client 实现——两章合读、你能同时用两种 SDK——跨语言调试时很有用。
9.21 五个"为什么 Client 的代码不能直接抄到 Server"
本章一直讲 Client——但读者可能好奇"Client 代码能不能当 Server 用"——答案是"不能"——五个理由:
1. 协议角色不对称——Client 发 initialize、Server 回 InitializeResult——_oninitialize handler 是 Server-only 的
2. Client 是"一对一"、Server 是"一对多"——Server 要管理多个 Client 的会话状态、Client 只管一个 Server
3. Client 有 JSON Schema 验证器(验证 Server 输出)、Server 有 Schema 定义(宣告工具参数)——方向相反
4. Client OAuth 是"获取 token"、Server OAuth 是"验证 token"——完全不同的流程
5. Client 的 listChanged 是"接收通知"、Server 的 listChanged 是"发送通知"——debounce 在两边逻辑完全不同
Client 和 Server 是"协议的两面"——语法相同、语义相反——不能互相套用。
9.22 两条实践建议:先查能力、受控环境开严格模式
建议一——调 API 前先用 getServerCapabilities() 查能力——不要假设 Server 支持什么。
建议二——对接的 Server 固定、能力已知的生产环境,建议打开 enforceStrictCapabilities: true——能力缺失会在本地立刻以 SdkError 暴露,而不是列表方法静默返回空列表(§9.3);要同时连很多能力各异的 Server 时,默认的宽松模式更合适(§9.10),靠建议一自己查能力即可。
9.23 buildContext 钩子的扩展哲学
packages/client/src/client/client.ts:248 有一个不起眼的方法:
protected override buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ClientContext {
return ctx;
}
看似一行默认实现——实际是给子类留的扩展点。Protocol 在处理每个收到的请求时调用它(packages/core/src/shared/protocol.ts:608),返回值就是交给请求处理器的 ctx——对 Client 来说,也就是 sampling / elicitation / roots 处理器拿到的第二个参数。注意 ClientContext 在 pin 版里就是 BaseContext 的别名(packages/core/src/shared/protocol.ts:280),想加字段需要自己扩展类型。
可能的用法——用户继承 Client 后、override buildContext 注入额外字段:
- 注入 当前 user_id(多用户 Agent 系统必备)
- 注入 追踪 span(分布式追踪的 trace/span id)
- 注入 feature flags 快照
- 注入 rate limit bucket
_transportInfo 参数——类型是 MessageExtraInfo,字段只有原始 HTTP request、authInfo 和两个关闭 SSE 流的回调(packages/core/src/types/types.ts:519-541),主要是服务端传输在填;pin 版的三个客户端内置传输调用 onmessage 时都不传这个参数,所以在 Client 里它通常是 undefined,只有自定义传输才可能填上。
这种"protected hook + default implementation"的设计——Java 的 TemplateMethod 模式、React 的 class component 生命周期——都是同一思路。
9.24 Zod Schema 的 runtime 二次校验
本章多处提到"schema 验证"——源码里的 _requestWithSchema 函数值得单独说。
Protocol 基类提供两个入口——注意两者都会校验返回值,区别只在 schema 从哪来:
request(request, options)——公开入口。它自己先const resultSchema = getResultSchema(request.method)按方法名查表拿到 schema,再转调下面那个(packages/core/src/shared/protocol.ts:781-787)_requestWithSchema(req, schema, options)——protected 入口,由调用方显式指定 schema。Client 的各个 API 全走这条,好让结果类型更精确:比如按方法名查表得到的tools/call结果 schema 是CallToolResult与CreateTaskResult的联合(packages/core/src/types/schemas.ts:2171),而callTool()显式传CallToolResultSchema,只接受普通的工具结果
为什么两条路都要验证返回值——
- JSON-RPC 协议本身不保证响应格式——Server bug 可能返回缺字段的结果
- Client 不信任 Server 返回——运行时 Zod 验证是最后防线
- 验证失败时 Promise 直接以
parseResult.errorreject(packages/core/src/shared/protocol.ts:878-882)。实跑:Server 在tools/list里返回name: 123,listTools()抛出的是 Zod 的ZodError(路径tools.0.name)——既不是ProtocolError也不是SdkError,调用方能据此区分"Server 挂了 / 报错"和"Server 返回畸形"
对比不验证的代价——未定义字段访问、静默 undefined 传给上游、bug 传到业务代码里才爆——调试地狱。所以 SDK 里没有"不校验返回值"的那条路——想跳过校验只能自己绕开 Protocol。
工程建议——任何外部 RPC 的响应都该 runtime 验证——"TypeScript 类型" 只在编译期管用、运行时值不守规矩你就完了。
9.25 Transport 抽象的"最小接口"
Transport interface(packages/core/src/shared/transport.ts)只要求 3 个必需方法 + 6 个可选成员——
interface Transport {
start(): Promise<void>
send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void>
close(): Promise<void>
onclose?: () => void
onerror?: (error: Error) => void
onmessage?: <T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void
sessionId?: string
setProtocolVersion?: (version: string) => void
setSupportedProtocolVersions?: (versions: string[]) => void
}
三种内置 transport 都实现它——但内部完全不同:
- stdio——
cross-spawn启动子进程 + stdin/stdout 流 - streamableHttp——
fetch+ReadableStream+ SSE - sse——
EventSource+ POST for writes
SDK 层只认 interface、不认实现——意味着用户能自己写 transport:
SDK 自带的 client transport 只有上面三个(packages/client/src/client/ 下的 stdio.ts / streamableHttp.ts / sse.ts),但 Transport 接口是对外导出的,你可以自己实现。移除 WebSocketClientTransport 的那份 changeset 结尾专门留了这句话:
The
Transportinterface remains exported for custom implementations.
换句话说,WebSocket 传输如今正是"自定义 transport"最现实的一个例子——SDK 不再提供,但接口还在,需要的人可以自己接。
这是"可扩展性的秘密"——接口小、职责清、依赖倒置——框架的标准套路。
9.26 第 9 章与第 20 章在这里汇合
本专栏 MCP 第 9 章(Client)+ 本专栏 MCP 第 20 章(Build Server)——一前一后、同一协议的两面。
第 9 章的 Client 通过 initialize 握手 → Server 回 InitializeResult → Client 存_serverCapabilities——这正是第 20 章§20.31 拆解的 _oninitialize 的对面。
listChanged 的去抖默认只有一层,就在 client 端(debounceMs,默认 300ms)。服务端默认不做合并——McpServer.sendToolListChanged() 只判断一次 isConnected() 就立即发出通知(见第 20 章 §20.18.3);只有构造 Server 时显式配置了 debouncedNotificationMethods,才会把同步连发的同类通知合并成一条(见第 3 章 §3.6)。所以默认情况下这里没有"双重保护",而是一个明确的分工:服务端如实报告事件,客户端决定以多快的节奏消费。
读者把这两章合起来读——你会发现 MCP 协议"不是一堆 API、是一张精心编排的对话脚本"——每个动作都有对等反应、每个等待都有超时兜底。
9.27 第 9 章 ↔ 第 20 章 对照表
| 设计决策 | 第 9 章引用 | 第 20 章引用 |
|---|---|---|
| 版本协商 | §9.2 | §20.31 |
| listChanged | §9.4 / §9.12 / §9.13 | §20.37 |
| 认证与 Host 校验 | §9.7 / §9.14-9.17 | §20.36(authInfo 透传)/ §20.33(DNS rebinding) |
| Transport 抽象 | §9.5 / §9.25 | §20.28 / §20.32 |
| Capability check | §9.9 | §20.22 |
| Schema validation | §9.24 | §20.34 (completable) |
六组对应关系——协议本身的对称美——理解一端就半理解另一端。
9.28 读完本章与第 20 章之后
把本章与第 20 章对照读,Client 与 Server 两端的交互就都能对上号,调试时可以从任意一端追到另一端。
本章余下各节是源码附录,可按需查阅;Python 侧的对应实现见第 11 章。
9.29 客户端初始化失败的 6 种真实场景
本章§9.2 讲了 "失败时调 close()"——实际生产里 init 失败的场景远不止网络错误——6 种真实情况:
1. 协议版本不兼容——Server 回的 protocolVersion 不在 Client 的 _supportedProtocolVersions 列表——connect() 抛 Error("Server's protocol version is not supported: …")(实跑)。pin 版默认列表是 2025-11-25、2025-06-18、2025-03-26、2024-11-05、2024-10-07(packages/core/src/types/constants.ts:3),所以跑旧版的老 Server 一般能连上;真正会失败的是 Server 回了一个 Client 不认识的版本(比如比 Client 更新的版本),或应用自己用 supportedProtocolVersions 收窄了列表。
2. OAuth flow 被用户中断——浏览器弹出授权页、用户直接关掉——SDK 不会在 connect() 里等回调:auth() 返回 REDIRECT 后传输层抛 UnauthorizedError,由应用在拿到授权码后调 transport.finishAuth(code) 再重连——应用自己等回调时必须有 timeout 兜底、不能永久等。
3. Server 端口没起——连接拒绝、握手前就断——Client 需要"retry with backoff"——但不能无限 retry(本专栏第 19 章 §19.7.3 的指数退避 + 最大次数)。
4. TLS 握手失败——自签证书、过期证书——fetch 直接抛错、connect() 随之失败——宿主应把原因明确告诉用户、不要悄悄吞掉。
5. Server 返回"capabilities 但值是 null"——spec 允许 capabilities 为空对象 {}、但不允许 null——Zod schema 会报错(实跑:connect() 抛 ZodError,expected object, received null)——这是"对外部 server 不信任"的典型场景。
6. notifications/initialized 发送失败——握手最后一步、Server 已回了 InitializeResult,但 Client 这条通知没发出去——TS Client 里 await this.notification(...) 抛错会落进 connect() 的 catch:关闭传输、把错误抛给调用方——Client 不会带着半初始化的状态继续跑,要做的是整条连接重来。
每一种场景都需要不同的 handler——工业级 Client 不是"能跑就行"、是"6 种 init 失败都有明确响应"。
9.30 _cachedToolOutputValidators.clear() 的微妙时机
本章 §9.3 工具元数据缓存中的 cacheToolMetadata 一上来就 clear()——为什么?
因为工具列表可能变化——listTools 重新拉取时、某个工具可能已被移除——如果不 clear、老 validator 还在 cache 里、占内存 + 可能出错。
反面方案——增量更新(detect added/removed/modified tools)——听起来更高效、实际:
- Server 返回的 tool 列表顺序不保证稳定
- "modified" 的检测需要深比较 outputSchema,省下的只是重新编译的那点开销
- 工具数通常不大,全量 clear + rebuild 已经足够便宜
简单 = 少 bug。
但这个 clear() 有一个副作用要知道:它不区分分页。listTools({ cursor }) 拉第二页时同样先清空,第一页工具的校验器就丢了。实跑:第一页含带 outputSchema 的工具 typed,拉完第一页后调用它会因结构化结果不合 schema 抛 ProtocolError;再拉第二页之后调用同一个工具,校验不再发生,原样返回。工具列表分页时,想让 outputSchema 校验对所有工具生效,只能在 SDK 之外自己做。
9.31 附录 A:各 transport 的握手差异
三种 transport 的 start() 行为不同——
- stdio.start():
spawn子进程、挂 listener 到 stdout——代价是一次进程创建 - streamableHttp.start():不建连、第一次 send 才 POST——
start()本身几乎不做事、代价被推迟到首个请求 - sse.start():建 EventSource 连接,
open之后还要等 Server 推来endpoint事件(告诉 Client 往哪个 URL POST)才算就绪(packages/client/src/client/sse.ts:176-197)——代价是一次完整的 HTTP 往返
三者的 start() 代价完全不在一个量级(进程创建 vs 什么都不做 vs 一次网络往返)——UI 上的 loading indicator 阈值要针对 transport 调整,具体数值请在你自己的目标环境上实测,不要照抄任何"通用经验值"。
设计启示——抽象 Transport interface 时、start() 的性能特征故意不约束——让不同 transport 各自优化——统一接口 + 差异化实现。
9.32 附录 B:三个常见的 Client 配置误区
误区 1——enforceStrictCapabilities 默认 false,对接固定 Server 的生产环境却没打开——见本章 §9.22——不开的话,Server 没声明能力时列表方法静默返回空列表(§9.3),问题要到下游才暴露。
误区 2——以为 jsonSchemaValidator 的默认实现很弱、想换成 AJV——其实 DefaultJsonSchemaValidator 在 Node.js 上本来就是 AJV(AjvJsonSchemaValidator),只有在 Cloudflare Workers(workerd)和浏览器环境才换成 CfWorkerJsonSchemaValidator(基于 @cfworker/json-schema,不做代码生成;见 packages/client/src/shimsNode.ts:6、packages/client/src/shimsWorkerd.ts:6、packages/client/src/shimsBrowser.ts:6)。真正需要自定义 jsonSchemaValidator 的场景是"要接自家的 schema 方言/缓存策略",而不是"追求更快"。
误区 3——listChanged 设 autoRefresh: true 但不处理 error——Server 不停发 list_changed、而随后的 tools/list 又不停失败时,每次都报错到 onChanged——UI 疯狂刷 toast——error handler 里要加"同一 error 一段时间内只报一次"的节流。
三个误区——都是照默认配置上线时容易踩的。
9.33 _pendingListChangedConfig 的时序艺术
本章§9.1 提到这个字段——值得单独拿出来看其时序图:
t=0 new Client({ listChanged: {...} })
→ _pendingListChangedConfig 保存
→ 此时 _serverCapabilities === undefined(还没握手)
t=1 client.connect(transport)
→ Protocol.connect() 启动 transport
→ 发送 initialize 请求
t=2 Server 返回 InitializeResult
→ _serverCapabilities 被赋值
t=3 发送 notifications/initialized
t=4 if (_pendingListChangedConfig) _setupListChangedHandlers(...)
→ 此时已能读 _serverCapabilities.tools.listChanged
→ _pendingListChangedConfig = undefined(防止重复注册)
关键在 t=4 前后——handler 注册的"正确时机"是"Server capability 已知 + initialized 通知已发"——提前注册会跟空 capabilities 比对、落空——延后注册会错过"Server 刚初始化就发的 list_changed"。
这个"暂存 + 延后绑定"模式——在 Event-driven 框架里很常见——React useEffect 的 mount 时机、Vue 的 mounted hook——同一时序思维。
9.34 ProtocolError vs SdkError 的语义分层
本章多处提到两种错误类型——它们的分工:
ProtocolError—— 违反 MCP 协议(InvalidRequest、InvalidParams、MethodNotFound 等、直接对应 JSON-RPC error codes)SdkError—— SDK 本地状态与传输层的错误(CapabilityNotSupported、NotConnected、RequestTimeout、ConnectionClosed 等、SDK 自己定义的字符串 code,packages/core/src/errors/sdkErrors.ts:9-37)
为什么要分两类——
ProtocolError可以序列化为 JSON-RPC error response、跨进程传递:Server 回的错误响应在 Client 这边就还原成ProtocolError;但 Client 也会在本地直接抛它(如 §9.3callTool的 outputSchema 校验失败)SdkError只在本地抛、从不上线
请求超时与取消也归 SdkError。每个请求默认 60 秒超时(DEFAULT_REQUEST_TIMEOUT_MSEC = 60_000,packages/core/src/shared/protocol.ts:100),可用 RequestOptions.timeout 改;超时后 Client 先向 Server 发 notifications/cancelled,再以 SdkError(code REQUEST_TIMEOUT)reject。用 signal 主动取消时同样发 notifications/cancelled,reject 的也是 code 为 REQUEST_TIMEOUT 的 SdkError,message 是 abort 的 reason(packages/core/src/shared/protocol.ts:846-866)。
实跑(STDIO):timeout: 200 的请求约 201 ms 后抛 SdkError REQUEST_TIMEOUT "Request timed out",线上紧跟一条 {"method":"notifications/cancelled","params":{"requestId":6,"reason":"SdkError: Request timed out"}}。对照:Python SDK 默认不设请求超时。
捕获策略——
try { await client.callTool({ name, arguments }) }
catch (e) {
if (e instanceof ProtocolError) { }
else if (e instanceof SdkError) { /* 本地错误:能力不支持、超时 / 取消、连接已断等 */ }
else { /* 其他:结果不合 schema 的 ZodError(§9.24)、网络等 */ }
}
// 注意:工具自己执行失败不会抛异常,而是返回 result.isError === true
三段式错误分层——让调用方能精准区分"Server 那边的错"、"我这边调用方式错"、"基础设施错"——不同对策不同。
9.35 客户端的 Keepalive 机制
MCP Client 没有自动的 "ping/pong" 心跳——但 ping() 方法存在(Client 自己定义在 packages/client/src/client/client.ts:720-722,发 ping 请求、期待空结果);反方向上,Protocol 构造时就注册了自动回复 ping 的处理器(packages/core/src/shared/protocol.ts:352-356),所以 Server 发来的 ping 不用你管。
何时用 ping——
- stdio 长连接——子进程可能卡死而不退出,定期 ping 能确认对端还在响应
- streamableHttp 长时间无请求——服务端可能主动清理空闲 session——ping 作为 keepalive
- health check——上层想知道"当前 MCP Server 是否可达"——
await client.ping()最快验证
频率——规范(2025-11-25 basic/utilities/ping.mdx)只要求实现方定期 ping、频率应当可配置、避免过度 ping,没有给具体数字——按你的网络环境和检测时效来定。
ping 失败的处理——规范说超时应视为连接失败,发送方可以认为连接已失效、断开或重连——不要默默继续。注意 2026-07-28 版已移除 ping(见该版 changelog),这一节是 2025-11-25 的形态。
9.36 发 PR 前的 Client 十问
集成完一个 MCP Client 后,上线前逐项核对:
enforceStrictCapabilities是否设为true?- 每次
callTool前是否检查getServerCapabilities().tools? - 带
outputSchema的工具响应是否做了结构化验证(§9.3)? listChanged的错误处理器是否做了节流(§9.32 误区 3)?- OAuth flow 是否设置了超时兜底(§9.29 场景 2)?
- ping 频率是否合理(§9.35)?
- 协议版本不兼容时是否有 fallback 路径(§9.29 场景 1)?
- 传输层选择是否合理——生产建议 streamableHttp,开发可用 stdio?
- token refresh 失败时是否正确清理 credential scope(§9.14)?
- 错误是否分三层捕获——
ProtocolError/SdkError/ 其他(§9.34)?
9.37 auth.ts 1745 行的结构速览
读本章而不读 auth 源码几乎等于没读 OAuth——但 1745 行太多——给一张"导航表"帮读者分段读:
| 段 | 起始行 | 内容 |
|---|---|---|
| 类型定义与适配 | 1-380 | AuthProvider (67) / isOAuthClientProvider (92) / handleOAuthUnauthorized (103) / adaptOAuthProvider (122) / OAuthClientProvider (143) |
| 客户端认证方法 | 381-519 | selectClientAuthMethod (400) / applyClientAuthentication (453) / applyBasicAuth (483) / applyPostAuth (495) / applyPublicAuth (505) |
Top-level auth() |
540-571 | 错误恢复 + 凭据失效重试(本章§9.14) |
determineScope |
572-600 | scope 选择优先级 |
authInternal 核心 |
601-819 | 编排整个 OAuth flow |
| URL / WWW-Authenticate 工具 | 820-964 | validateClientMetadataUrl (820) / selectResourceURL (843) / extractWWWAuthenticateParams (871) |
| Discovery | 965-1363 | RFC 9728 (965) + RFC 8414 (1106/1225) 双轨,入口 discoverOAuthServerInfo (1317) |
| Authorization flow | 1364-1533 | startAuthorization (1364) + PKCE,prepareAuthorizationCodeRequest (1444) / executeTokenRequest (1461) |
| Token exchange | 1534-1702 | exchangeAuthorization (1534) / refreshAuthorization (1580) / fetchToken (1643) |
| Client registration | 1703-1745 | RFC 7591 DCR:registerClient |
建议的阅读顺序:
- 顶层
auth()(540 行)——入口的错误恢复 authInternal(601 行)——完整 flow 编排discoverOAuthServerInfo(1317 行)——RFC 发现细节exchangeAuthorization(1534)/refreshAuthorization(1580)——token 交换
9.38 middleware 的三个实用模式
middleware.ts 319 行、§9.8 讲了基础结构——这里给三个实用模式:
模式 1:请求 tracing(vendor-neutral)
const withTracing = (tracer: Tracer) => createMiddleware(
async (next, input, init) => {
const span = tracer.startSpan('http.request', { attributes: { url: input.toString() } })
try {
const res = await next(input, init)
span.setAttribute('http.status_code', res.status)
return res
} finally { span.end() }
}
)
模式 2:per-user rate limiting
const withRateLimit = (limiter: RateLimiter, getUserKey: (input: string | URL) => string) => createMiddleware(
async (next, input, init) => {
const key = getUserKey(input)
await limiter.acquire(key)
return next(input, init)
}
)
模式 3:automatic retry with jitter
const withRetry = (opts: { maxRetries: number, baseMs: number }) => createMiddleware(
async (next, input, init) => {
for (let i = 0; ; i++) {
const res = await next(input, init)
if (res.status < 500 || i >= opts.maxRetries) return res
await new Promise(r => setTimeout(r, opts.baseMs * 2 ** i * (0.5 + Math.random())))
}
}
)
// 注意:请求体若是一次性的流(ReadableStream),重试时无法重放
三个模式组合:applyMiddlewares(withTracing(t), withRateLimit(l, k), withRetry({...}), withOAuth(p))——一次 fetch 同时获得 trace、rate limit、retry 和 auth 四种能力。这种洋葱模型在 Express、Koa、Redux 等框架中都有对应实现。
9.39 MCP Client 的长生命周期状态机
TypeScript Client 不是单次调用的函数,而是一个长期持有多种状态的对象。下面是便于理解的概念划分——SDK 源码里并没有这样一个状态枚举,这些状态散落在 _transport、_serverCapabilities 等字段和 OAuth Provider 里:
uninitialized——刚new出来connecting——connect()进行中handshaking——等 Server initialize 响应ready——capabilities 已知、可以发业务请求reauthenticating——OAuth token 过期、重新走 flowdisconnected——主动close()或 transport 挂failed——不可恢复的错误
本章前面的大部分小节都在讲两个状态之间的某条边——把这张图在脑中构建清楚,细节就有了挂靠点。
9.40 与 Anthropic 官方 SDK 的关系
@anthropic-ai/sdk(Claude API)和 MCP TypeScript SDK 是两套不同的 SDK,各司其职:
@anthropic-ai/sdk——与 Anthropic 的 LLM API 通信(messages / models 等)@modelcontextprotocol/client/@modelcontextprotocol/server——与 MCP Server 通信(tools / resources / prompts)。注意 2.0(pin 时为2.0.0-alpha)起 TS SDK 拆成了 pnpm monorepo:仓库根的@modelcontextprotocol/sdk和packages/core下的@modelcontextprotocol/core都标为 private,对外发布的是client/server,以及packages/middleware/下的express/fastify/hono/node四个适配包
一个典型的 Claude Desktop 式宿主同时使用两者:对上用 @anthropic-ai/sdk 调 Claude,对下用 @modelcontextprotocol/client 连接每个 MCP Server,中间层把 MCP Server 的 tool list 翻译成 Claude API 的 tools 参数。
9.41 Client API 速查表
| 想做 | API | 本章章节 |
|---|---|---|
| 连接 MCP Server | client.connect(transport) |
§9.2 |
| 列出工具 | client.listTools() |
§9.3 |
| 调用工具 | client.callTool({ name, arguments }) |
§9.3 |
| 订阅列表变更 | new Client({ listChanged: {...} }) |
§9.4 / §9.12 |
| OAuth 集成 | OAuthClientProvider |
§9.7 / §9.14 |
| 处理 Server 反向请求 | client.setRequestHandler(...) |
§9.6 |
| 切换 transport | 传入不同 Transport | §9.5 / §9.25 |
| 增强 fetch | applyMiddlewares(...) |
§9.8 / §9.38 |
| 健康检查 | client.ping() |
§9.35 |
| 关闭 | client.close() |
§9.2 |