MCP 协议设计与实现
第9章 TypeScript Client 实现剖析
第09章 TypeScript Client 实现剖析
“A protocol is only as good as its client implementation. The client is the bridge between the AI model and the world of tools.”
本章要点
- 理解 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 应用(如 Claude Desktop、Cursor)与 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 初始化握手:
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: 建立底层连接<br/>(stdio/HTTP/SSE)
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
让我们逐步分析这个流程中的关键逻辑:
首次连接:完整的初始化握手
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;
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 类传输(StreamableHTTP)实现。调用后,传输层会在每个后续请求的 HTTP 头中携带 mcp-protocol-version,确保 Server 端能正确路由请求。
重连:跳过握手的快速路径
重连逻辑非常精简:当 transport.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 和 listPrompts 遵循完全相同的模式。宽松模式的设计意图是提升容错性:一个 Client 可能同时连接多个 Server,部分 Server 不支持 tools 是正常的。返回空列表比抛出异常更友好。
而 readResource、callTool、getPrompt 这些”操作型”API 则总是严格检查能力,因为对不存在的能力发起操作本身就是逻辑错误。
callTool:结构化输出验证
callTool 是所有 API 中最复杂的,因为它涉及 outputSchema 验证和任务型工具的拦截:
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时就已编译并缓存,避免了每次调用的重复编译开销。
工具元数据缓存
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?) { ... }
subscribeResource 有一个额外的能力检查——不仅要求 Server 支持 resources,还要求 resources.subscribe 为 true。这是因为资源订阅是资源能力的一个子特性,并非所有支持资源的 Server 都实现了变更通知。
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:#3b82f6,color:#fff,stroke:none
style K fill:#10b981,color:#fff,stroke:none
style L fill:#8b5cf6,color:#fff,stroke:none
防抖机制的实现
SDK 对每种列表类型(tools、prompts、resources)维护独立的防抖定时器:
private _setupListChangedHandler<T>(
listType: string,
notificationMethod: NotificationMethod,
options: ListChangedOptions<T>,
fetcher: () => Promise<T[]>
): void {
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() 获取最新数据。
使用方式非常直观:
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);
}
}
}
}
);
9.5 Transport 选择与连接策略
Client 本身不绑定任何特定的传输层实现。它通过 connect(transport) 接受一个 Transport 接口的实例,由上层应用决定使用哪种传输方式。SDK 提供了三种内置传输:
| 传输类型 | 类名 | 适用场景 |
|---|---|---|
| Stdio | StdioClientTransport | 本地子进程,开发调试 |
| Streamable HTTP | StreamableHTTPClientTransport | 远程服务,生产部署 |
| SSE (旧版) | SSEClientTransport | 兼容旧版 Server |
SDK 官方推荐的连接策略是先尝试 Streamable HTTP,失败后降级到 SSE:
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() 失败时会调用 this.close(),Client 的内部状态已经被清理,不适合重用。
重连逻辑的设计意图
回顾 connect() 中的重连检测:
if (transport.sessionId !== undefined) {
if (this._negotiatedProtocolVersion !== undefined
&& transport.setProtocolVersion) {
transport.setProtocolVersion(
this._negotiatedProtocolVersion);
}
return;
}
这段代码揭示了 MCP 的重连哲学:会话恢复,而非重新建立。当 HTTP 传输因网络中断后恢复时,传输层会携带之前的 sessionId。Client 检测到这一点后,只需将协议版本号同步到新的传输层实例,即可恢复通信。Server 端通过 sessionId 识别出这是同一个客户端,保留之前的上下文。
9.6 请求处理器:Server 发起的请求
MCP 协议不是单向的——Server 也可以向 Client 发起请求。Client 通过 setRequestHandler 注册处理器来响应这些反向请求:
// 处理 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' }
};
}
);
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:#fff,stroke:none
style D fill:#f59e0b,color:#fff,stroke:none
style H fill:#3b82f6,color:#fff,stroke:none
style I fill:#3b82f6,color:#fff,stroke:none
这个包装层做了三件事:
- 入参验证——用 Zod Schema 验证 Server 发来的请求格式
- 能力检查——对 elicitation 请求,检查 Client 是否声明了对应的模式支持(form/url)
- 出参验证——对用户 handler 返回的结果进行 Schema 验证,防止返回不合规的响应
特别值得注意的是 Elicitation 的 applyDefaults 机制。当 Client 声明了 elicitation.form.applyDefaults: true,且用户接受了 elicitation 请求时,SDK 会自动将 Schema 中声明的默认值填充到用户提交的数据中:
if (validatedResult.action === 'accept'
&& validatedResult.content
&& requestedSchema
&& this._capabilities.elicitation?.form?.applyDefaults) {
applyElicitationDefaults(
requestedSchema, validatedResult.content);
}
这个自动填充逻辑递归处理嵌套对象和 anyOf / oneOf 组合 Schema,是一个贴心的开发者体验优化。
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 -->|是| K["交换令牌<br/>fetchToken()"]
J -->|否| L{是否有<br/>refresh_token?}
L -->|是| M["刷新令牌<br/>refreshAuthorization()"]
L -->|否| N["启动授权流程<br/>startAuthorization()"]
K --> O[保存 tokens<br/>返回 AUTHORIZED]
M -->|成功| O
M -->|失败| N
N --> P[重定向用户<br/>返回 REDIRECT]
end
style D fill:#3b82f6,color:#fff,stroke:none
style K fill:#10b981,color:#fff,stroke:none
style M fill:#f59e0b,color:#fff,stroke:none
style N fill:#ec4899,color:#fff,stroke:none
auth() 函数是整个 OAuth 流程的编排器。它的错误恢复策略值得研究——外层 auth() 捕获特定的 OAuth 错误码并进行凭据失效后重试:
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 自动选择最佳方案:
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;
}
// 按安全级别降序选择
// 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。这确保了在安全性和兼容性之间取得最佳平衡。
双层适配
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 {
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(); // 重试
}
}
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 使用(Transport 有内置的 OAuth 处理),但它为使用 MCP OAuth 基础设施进行通用 HTTP 请求提供了便利。例如,一个应用可能需要用 MCP Server 签发的 token 去访问其他 API 端点——这时中间件链就派上了用场。
9.9 能力断言体系
Client 实现了一套完整的能力断言机制,确保请求不会发送到不支持对应功能的 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 声明了处理该请求的能力 |
这三维断言形成了一个双向能力检查网:Client 不会向不支持的 Server 发请求,也不会在没有声明能力的情况下处理 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 实现,以及各种传输层的具体实现细节。
9.11 client 模块完整源码地图——5908 行的六大支柱
打开 packages/client/src/client/——5908 行代码、分布在六个文件:
| 文件 | 行 | 职责 |
|---|---|---|
client.ts | 1068 | Client 类本身 |
auth.ts | 1745 | OAuth 2.1 完整实现(RFC 8414 / 9728 / 7591) |
authExtensions.ts | 702 | PKCE / client metadata / discovery 扩展 |
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 行——大致平均——没有”主推”的传输、每种都是 first-class
- middleware 319 行 + crossAppAccess 303 行——表明 MCP SDK 在企业集成场景下有专门投入
这个目录是任何想”自己写个 Agent 框架”的人”值得抄一遍”的样板——模块边界清晰、职责单一、每个文件都能独立读完。
9.12 _listChangedDebounceTimers 的 Map 为什么必要
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 的 useDeferredValue、Vue 3 的 watcher scheduler 里都能看到(本书另外两本《React 18》和《Vue 3》有对应章节)——同一模式、不同语境。
9.13 _setupListChangedHandlers 的三重 guard
client.ts:258-278——注册 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 种(auth.ts:246):
invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void | Promise<void>;
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 给了简化版——真实源码(auth.ts 约 770 行附近)还有一条被忽略的路径:
- 路径 1:DCR 指定——
token_endpoint_auth_method字段直接拿——最高优先级 - 路径 2:按能力降序——
client_secret_basic>client_secret_post>none - 路径 3:fallback——如果 Server 不支持列出来的任何一种——用 client_secret 就 post、没有就 none——让流程能继续、避免死锁
第 3 条 fallback 是细节——RFC 6749 其实不要求 Server 声明 supported methods——一些老 Server 根本没这个字段——Client 不能因此放弃。
这段 fallback 代码是 MCP SDK 在”实用主义 > 规范”的一个例证——严格按 spec 写会导致实际不能用——工程要容错。
9.16 discoverOAuthServerInfo 的 RFC 双轨
auth.ts:1317 的 discoverOAuthServerInfo 实现两套发现机制并联:
- RFC 9728 (2024-09)——Protected Resource Metadata——资源 server 通过
WWW-Authenticateheader 指向 authorization server - RFC 8414 (2018)——Authorization Server Metadata——老的元数据端点
/.well-known/oauth-authorization-server
SDK 先尝试 9728、失败后回退 8414——兼容新老 Server。
为什么 MCP 要选 9728——
- 9728 明确区分 resource server 和 authorization server——Agent 场景下 MCP Server 是”resource server”、OAuth 通常由单独 identity provider 做
- 老的 8414 假设 “resource = authorization”——企业 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 实现的机制——
- Client 把 IdP 的 access_token 携带到各 MCP Server 的请求头
- MCP Server 反向校验(调 IdP 的 introspection endpoint)
- 无需每个 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 机制。
工程要点——
- Client 必须持久化最后收到的 event ID(不能只放内存)
- 重连时间窗口内 Server 也要保留事件(默认 Anthropic SDK 15 分钟)
- Event ID 必须全局递增——否则 Server 无法断点定位
这呼应本书第 11 章§11.4 讨论的”可恢复性”**——短期记忆的压缩、长期记忆的持久化、传输层的事件回放——三者都是”防止信息丢失”的不同层次的防御。
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 行代码 → 约 200 行内部逻辑被触发——好 SDK 的特征就是”用起来简单、底下工程扎实”。
9.20 Python vs TypeScript SDK 的取舍差异
MCP 还有一个 Python SDK(modelcontextprotocol/python-sdk)——两个 SDK 体现不同的社区偏好:
| 维度 | TypeScript SDK | Python SDK |
|---|---|---|
| 主要用户 | Claude Desktop / IDE 插件 | AI 研究员 / 数据科学 |
| 风格 | OOP + 继承 | 更多装饰器 + 函数式 |
| 类型系统 | Zod runtime + TS 静态 | Pydantic runtime + typing |
| Async 模型 | Promise / async generator | asyncio / async context manager |
| OAuth 完整度 | 1745 行完整 RFC | 简化版 |
| Streamable HTTP | 761 行生产实现 | 较新、功能略少 |
TypeScript SDK 更”企业级”——面向”部署到用户机器上的长期运行 Agent”。
Python SDK 更”研究友好”——面向”快速验证 Agent 原型”。
选择原则——**“用户终端” → TS,“后台 batch / 研究” → Python——两者生成的 Server 互操作、不锁定。
这呼应本书第 14 章 Python SDK 实现——两章合读、你能同时用两种 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 结尾:两条送给读者的硬原则
原则一——永远通过 getServerCapabilities() 先查、再调 API——不要假设 Server 支持什么——假设 = bug 种子。
原则二——永远用 enforceStrictCapabilities: true 在生产环境——开发时宽松容错、生产时严格防御——两阶段用不同配置。
两条原则——让你的 Client 既灵活又安全。
9.24 buildContext 钩子的扩展哲学
client.ts:249 有一个不起眼的方法:
protected override buildContext(ctx: BaseContext, _transportInfo?: MessageExtraInfo): ClientContext {
return ctx;
}
看似一行默认实现——实际是给子类留的扩展点。
典型用法——用户继承 Client 后、overide buildContext 注入额外字段:
- 注入 当前 user_id(多用户 Agent 系统必备)
- 注入 追踪 span(本书第 19 章可观测性)
- 注入 feature flags 快照(本书第 20 章讨论过
getFeatureValue_CACHED_MAY_BE_STALE) - 注入 rate limit bucket
_transportInfo 参数——携带 transport 层的”请求来源”信息(HTTP 的 IP、TLS 的 client cert 等)——让 Agent 能做”按 transport 的策略”。
这种”protected hook + default implementation”的设计——Java 的 TemplateMethod 模式、React 的 class component 生命周期——都是同一思路。
9.25 Zod Schema 的 runtime 二次校验
本章多处提到”schema 验证”——源码里的 _requestWithSchema 函数值得单独说。
Protocol 基类提供两种请求方法——
request(method, params)——只发、不验证返回值_requestWithSchema(req, schema, options)——发 + 用 Zod schema 验证返回值
为什么每种调用都用后者——
- JSON-RPC 协议本身不保证响应格式——Server bug 可能返回缺字段的结果
- Client 不信任 Server 返回——运行时 Zod 验证是最后防线
- 验证失败抛
ProtocolError——调用方能区分”Server 挂了”和”Server 返回畸形”
对比不验证的代价——未定义字段访问、静默 undefined 传给上游、bug 传到业务代码里才爆——调试地狱。
工程建议——任何外部 RPC 的响应都该 runtime 验证——**“TypeScript 类型” 只在编译期管用、运行时值不守规矩你就完了。
9.26 Transport 抽象的”最小接口”
Transport interface 只要求 6 个方法——
interface Transport {
start(): Promise<void>
close(): Promise<void>
send(message: JSONRPCMessage): Promise<void>
onclose?: () => void
onerror?: (error: Error) => void
onmessage?: (message: JSONRPCMessage) => void
sessionId?: string
setProtocolVersion?: (version: string) => void
}
三种内置 transport 都实现它——但内部完全不同:
- stdio——
child_process.spawn+ stdin/stdout 流 - streamableHttp——
fetch+ReadableStream+ SSE - sse——
EventSource+ POST for writes
SDK 层只认 interface、不认实现——意味着用户能自己写 transport:
WebSocketClientTransport——自定义 WebSocket 传输TestClientTransport——测试用、直接 push 预设消息BrowserExtensionTransport——Chrome extension message passing
这是”可扩展性的秘密”——接口小、职责清、依赖倒置——框架的标准套路。
9.27 两本书在本章汇合
本书 MCP 第 9 章(Client)+ 本书 MCP 第 20 章(Build Server)——一前一后、同一协议的两面。
第 9 章的 Client 通过 initialize 握手 → Server 回 InitializeResult → Client 存_serverCapabilities——这正是第 20 章§20.31 拆解的 _oninitialize 的对面。
本章的 listChanged debounce(配置在 client 端)——和第 20 章§20.34.95 提到的 _notifyToolListChanged 100ms debounce(server 端)——是”同一问题的两端”:Server 批量 coalesce 发通知、Client 再做 debounce 防止批量后仍抖动——双重保护。
读者把这两章合起来读——你会发现 MCP 协议”不是一堆 API、是一张精心编排的对话脚本”——每个动作都有对等反应、每个等待都有超时兜底。
9.28 最后一张表
| 设计决策 | 第 9 章引用 | 第 20 章引用 |
|---|---|---|
| 版本协商 | §9.2 | §20.31 |
| listChanged | §9.4 / §9.12 / §9.13 | §20.34.95 |
| OAuth | §9.7 / §9.14-9.17 | §20.33 (DNS rebinding) |
| Transport 抽象 | §9.5 / §9.26 | §20.30 / §20.32 |
| Capability check | §9.9 | §20.22 |
| Schema validation | §9.25 | §20.34.5 (completable) |
12 对对应关系——协议本身的对称美——理解一端就半理解另一端。
9.29 真结尾
读完本章 + 第 20 章——你就是能”双向”看懂 MCP 协议的人——能写 Client、能写 Server、能调试两端交互——这是”MCP 工程师”的门槛。
下章 Python Client 见、再见。
9.30 客户端初始化失败的 6 种真实场景
本章§9.2 讲了 “失败时调 close()”——实际生产里 init 失败的场景远不止网络错误——6 种真实情况:
1. 协议版本不兼容——Server 回的 protocolVersion 不在 Client 的 _supportedProtocolVersions 列表——MCP spec 在演进、偶有老 Server 还在跑旧版——2024-11-05 和 2025-03-26 是两个真实 version string。
2. OAuth flow 被用户中断——浏览器弹出授权页、用户直接关掉——Client 等 callback 超时——必须有 timeout 兜底、不能永久等。
3. Server 端口没起——连接拒绝、握手前就断——Client 需要”retry with backoff”——但不能无限 retry(本书第 3 章§3.29 的指数退避 + 最大次数)。
4. TLS 握手失败——自签证书、过期证书——Claude Desktop 的对策:UI 弹窗让用户”trust this server”——不是悄悄拒绝。
5. Server 返回”capabilities 但值是 null”——spec 允许 capabilities 为空对象 {}、但不允许 null——Zod schema 会报错——这是”对外部 server 不信任”的典型场景。
6. notifications/initialized 发送失败——握手最后一步、Server 已经准备完毕但 Client 这条通知网络挂了——Server 视角:Client 没 init 完——Client 视角:Server 已回了 InitializeResult——两端状态不一致、后续调用都会失败。
每一种场景都需要不同的 handler——工业级 Client 不是”能跑就行”、是”6 种 init 失败都有明确响应”。
9.31 _cachedToolOutputValidators.clear() 的微妙时机
本章§9.3.3 的 cacheToolMetadata 一上来就 clear()——为什么?
因为工具列表可能变化——listTools 重新拉取时、某个工具可能已被移除——如果不 clear、老 validator 还在 cache 里、占内存 + 可能出错。
反面方案——增量更新(detect added/removed/modified tools)——听起来更高效、实际:
- Server 返回的 tool 列表顺序不保证稳定
- “modified” 的检测需要深比较 outputSchema、成本 > 重新 compile
clear + rebuildO(N) vs 增量更新 O(N²)
对于 N < 100 的场景(大多数 MCP Server 工具数)——全量 clear 反而更优——简单 = 快 = 少 bug。
9.32 附录 A:各 transport 的握手差异
三种 transport 的 start() 行为不同——
- stdio.start():
spawn子进程、挂 listener 到 stdout——10-50ms - streamableHttp.start():不建连、第一次 send 才 POST——零延迟、但首 request 慢
- sse.start():建 EventSource 连接、等第一个 event_open 事件——HTTP Upgrade handshake ~100-200ms
这三种差异让client.connect() 的延迟横跨 10ms - 200ms——UI 上的 loading indicator 阈值要针对 transport 调整。
设计启示——抽象 Transport interface 时、start() 的性能特征故意不约束——让不同 transport 各自优化——统一接口 + 差异化实现。
9.33 附录 B:三个常见的 Client 配置误区
误区 1——enforceStrictCapabilities 默认 false、生产应该 true——本章§9.22 已经讲过——但很多团队不改——导致生产静默错误。
误区 2——jsonSchemaValidator 不自定义——Default validator 通常够用、但在”高性能场景”可以换 AJV 或 Zod(性能 2-10× 差距)——有时值得。
误区 3——listChanged 设 autoRefresh: true 但不处理 error——Server 挂了之后 Client 反复尝试 listTools、每次都报错到 onChanged——UI 疯狂刷 toast——error handler 里要加”同一 error 5 分钟内只报一次”的节流。
三个误区——每个团队都会至少栽倒一次——提前知道能省 1 周调试。
9.35 _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.36 ProtocolError vs SdkError 的语义分层
本章多处提到两种错误类型——它们的分工:
ProtocolError—— 违反 MCP 协议(InvalidRequest、InvalidParams、MethodNotFound 等、直接对应 JSON-RPC error codes)SdkError—— 违反 SDK 使用约定(CapabilityNotSupported、AlreadyConnected、NotConnected 等、SDK 自己定义)
为什么要分两类——
ProtocolError会序列化为 JSON-RPC error response、跨进程传递SdkError只在 client process 本地抛、不跨进程
捕获策略——
try { await client.callTool({ name, arguments }) }
catch (e) {
if (e instanceof ProtocolError) { /* 是 server 端的业务错 */ }
else if (e instanceof SdkError) { /* 是本地调用错 */ }
else { /* 是网络 / OOM 等系统错 */ }
}
三段式错误分层——让调用方能精准区分”Server 那边的错”、“我这边调用方式错”、“基础设施错”——不同对策不同。
9.37 客户端的 Keepalive 机制
MCP Client 没有显式的 “ping/pong” 心跳——但 ping() 方法存在(client.ts 里 Client 继承了 Protocol.ping)。
何时用 ping——
- 长连接 (stdio) 防止 OS pipe buffer 卡死——每 N 秒 ping 一次、确保双向都活着
- streamableHttp 长时间无请求——服务端可能主动清理空闲 session——ping 作为 keepalive
- health check——上层想知道”当前 MCP Server 是否可达”——
await client.ping()最快验证
频率推荐——30-60 秒——太频繁浪费、太稀疏检测慢。
ping 失败的处理——立刻 reconnect、或 fail over 到备用 Server——不要默默继续。
9.38 发 PR 前的 Client 十问
集成完一个 MCP Client 后,上线前逐项核对:
enforceStrictCapabilities是否设为true?- 每次
callTool前是否检查getServerCapabilities().tools? - 带
outputSchema的工具响应是否做了结构化验证(§9.3)? listChanged的错误处理器是否做了节流(§9.33 误区 3)?- OAuth flow 是否设置了超时兜底(§9.30 场景 2)?
- ping 频率是否合理(§9.37)?
- 协议版本不兼容时是否有 fallback 路径(§9.30 场景 1)?
- 传输层选择是否合理——生产建议 streamableHttp,开发可用 stdio?
- token refresh 失败时是否正确清理 credential scope(§9.14)?
- 错误是否分三层捕获——
ProtocolError/SdkError/ 其他(§9.36)?
9.39 auth.ts 1745 行的结构速览
读本章而不读 auth 源码几乎等于没读 OAuth——但 1745 行太多——给一张”导航表”帮读者分段读:
| 段 | 行号大致 | 内容 |
|---|---|---|
| 类型定义 | 1-300 | OAuthClientProvider / AuthProvider / OAuthError / 各种 schema |
Top-level auth() | 540-600 | 错误恢复 + 凭据失效重试(本章§9.14) |
authInternal 核心 | 600-850 | 编排整个 OAuth flow |
| Client registration | 850-950 | RFC 7591 DCR |
| Discovery | 950-1400 | RFC 9728 + RFC 8414 双轨 |
| Authorization flow | 1400-1550 | startAuthorization + PKCE |
| Token exchange | 1550-1700 | exchangeAuthorization + refreshAuthorization |
| Utility | 1700-1745 | 签名、URL 构造等 helpers |
建议的阅读顺序:
- 顶层
auth()(约 540 行)——入口的错误恢复 authInternal(约 600 行)——完整 flow 编排discoverOAuthServerInfo(约 1317 行)——RFC 发现细节exchangeAuthorization/refreshAuthorization——token 交换
9.40 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: (req) => 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 <= opts.maxRetries; 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())))
}
}
)
三个模式组合:applyMiddlewares(withTracing(t), withRateLimit(l, k), withRetry({...}), withOAuth(p))——一次 fetch 同时获得 trace、rate limit、retry 和 auth 四种能力。这种洋葱模型在 Express、Koa、Redux 等框架中都有对应实现。
9.41 MCP Client 的长生命周期状态机
TypeScript Client 不是单次调用的函数,而是一个长期持有多种状态的对象:
uninitialized——刚new出来connecting——connect()进行中handshaking——等 Server initialize 响应ready——capabilities 已知、可以发业务请求reauthenticating——OAuth token 过期、重新走 flowdisconnected——主动close()或 transport 挂failed——不可恢复的错误
本章前面的每一节都在讲两个状态之间的某条边——把这张图在脑中构建清楚,所有细节都有挂靠点。
9.42 与 Anthropic 官方 SDK 的关系
@anthropic-ai/sdk(Claude API)和 @modelcontextprotocol/sdk 是两个不同的 SDK,各司其职:
@anthropic-ai/sdk——与 Anthropic 的 LLM API 通信(messages / models / completions)@modelcontextprotocol/sdk——与 MCP Server 通信(tools / resources / prompts)
一个典型的 Claude Desktop 式宿主同时使用两者:对上用 @anthropic-ai/sdk 调 Claude,对下用 @modelcontextprotocol/sdk/client 连接每个 MCP Server,中间层把 MCP Server 的 tool list 翻译成 Claude API 的 tools 参数。
9.43 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.26 |
| 增强 fetch | applyMiddlewares(...) | §9.8 / §9.40 |
| 健康检查 | client.ping() | §9.37 |
| 关闭 | client.close() | §9.2 |