MCP 协议设计与实现

第8章 TypeScript Server 实现剖析

作者 杨艺韬 · 11,677 字 · 发布于 · 更新于

在前面的章节中,我们已经理解了 MCP 协议的整体架构与传输层机制。从本章开始,我们将深入 MCP TypeScript SDK 的服务端实现,逐行剖析源码中最核心的设计决策。MCP 的服务端是整个生态系统的基石——它决定了开发者如何定义工具、暴露资源、管理提示词,以及如何将这些能力安全地交付给 AI 客户端。

SDK 的服务端采用了一个精妙的双层架构:底层的 Server 类负责协议级别的通信细节,上层的 McpServer 类则提供开发者友好的注册式 API。这种分层设计既保证了协议实现的严谨性,又让日常开发变得简洁直观。本章将从这个架构出发,逐步揭示工具注册、请求分发、输入输出校验、自动补全、中间件防护以及多框架集成的完整实现链路。

8.1 双层服务端架构:设计哲学与职责划分

MCP TypeScript SDK 的服务端由两个核心类构成,分别位于不同的抽象层次。理解它们的职责边界,是理解整个服务端实现的前提。

graph TB
    subgraph "开发者视角"
        Dev[应用代码]
        Dev -->|"server.registerTool()"| McpServer
        Dev -->|"server.registerResource()"| McpServer
        Dev -->|"server.registerPrompt()"| McpServer
        Dev -->|"server.connect(transport)"| McpServer
    end
    subgraph "McpServer 层 (mcp.ts)"
        McpServer["McpServer<br/>高层 API 封装"]
        McpServer -->|"内部持有"| RegTools["_registeredTools<br/>工具注册表"]
        McpServer -->|"内部持有"| RegRes["_registeredResources<br/>资源注册表"]
        McpServer -->|"内部持有"| RegPrompts["_registeredPrompts<br/>提示词注册表"]
    end
    subgraph "Server 层 (server.ts)"
        Server["Server extends Protocol<br/>协议级实现"]
        Server -->|"管理"| ReqHandlers["请求处理器映射<br/>_requestHandlers"]
        Server -->|"管理"| NotifHandlers["通知处理器映射<br/>_notificationHandlers"]
        Server -->|"管理"| Capabilities["能力声明<br/>_capabilities"]
    end
    McpServer -->|"this.server"| Server
    Server -->|"connect()"| Transport["Transport<br/>传输层"]
    style McpServer fill:#e1f5fe
    style Server fill:#fff3e0

8.1.1 Server 类:协议层的忠实执行者

Server 类定义在 packages/server/src/server/server.ts(b8886e7 实测 677 行),它继承自 Protocol<ServerContext> 基类。Protocol 是 MCP SDK 中客户端和服务端共享的通信基础设施,提供了 JSON-RPC 消息的收发、请求-响应匹配、超时管理、取消通知等底层能力。

Server 在 Protocol 之上增加了服务端特有的职责。注意 pin 版在这个类的 JSDoc 上标了 @deprecated:高层 API 请用 McpServer,Server 只留给高级场景(packages/server/src/server/server.ts:92-98)。

// packages/server/src/server/server.ts 第99-141行(节选:省略 _experimental 字段、oninitialized 回调与剥离 tasks 运行时字段的分支)
export class Server extends Protocol<ServerContext> {
    private _clientCapabilities?: ClientCapabilities;
    private _clientVersion?: Implementation;
    private _capabilities: ServerCapabilities;
    private _instructions?: string;
    private _jsonSchemaValidator: jsonSchemaValidator;

    constructor(
        private _serverInfo: Implementation,
        options?: ServerOptions
    ) {
        super({
            ...options,
            tasks: extractTaskManagerOptions(options?.capabilities?.tasks)
        });
        this._capabilities = options?.capabilities ? { ...options.capabilities } : {};
        this._instructions = options?.instructions;
        this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator();

        // 自动处理初始化握手
        this.setRequestHandler('initialize', request => this._oninitialize(request));
        this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.());

        if (this._capabilities.logging) {
            this._registerLoggingHandler();
        }
    }
}

Server 类的核心职责包括:

  1. 初始化握手:自动处理 initialize 请求,协商协议版本,交换能力声明
  2. 能力管理:通过 registerCapabilities() 动态注册服务端能力,并在 assertRequestHandlerCapability() 中校验能力与请求方法的匹配关系
  3. 请求校验:覆写 setRequestHandler() 方法,对 tools/call 请求施加额外的 Schema 验证(packages/server/src/server/server.ts 第225-272行)
  4. 通知权限检查:在 assertNotificationCapability() 中确保服务端只发送已声明能力范围内的通知
  5. 客户端交互:提供 createMessage()(采样请求)、elicitInput()(用户输入获取)、listRoots() 等与客户端交互的方法

特别值得注意的是 Server 对 tools/call 的特殊处理。在 setRequestHandler 的覆写中(同文件第225-272行),当 method 为 tools/call 时,SDK 会用一个包装函数替换原始 handler,对请求参数和返回结果分别做 Schema 校验:

// packages/server/src/server/server.ts 第225-272行(节选:省略 errorMessage 的拼装,以及请求带 task 参数时改用 CreateTaskResultSchema 的分支,见 §8.8.4)
public override setRequestHandler<M extends RequestMethod>(
    method: M,
    handler: (request: RequestTypeMap[M], ctx: ServerContext) => ResultTypeMap[M] | Promise<ResultTypeMap[M]>
): void {
    if (method === 'tools/call') {
        const wrappedHandler = async (request, ctx): Promise<ServerResult> => {
            const validatedRequest = parseSchema(CallToolRequestSchema, request);
            if (!validatedRequest.success) {
                throw new ProtocolError(ProtocolErrorCode.InvalidParams,
                    `Invalid tools/call request: ${errorMessage}`);
            }
            const { params } = validatedRequest.data;
            const result = await Promise.resolve(handler(request, ctx));
            // if (params.task) { ...按 CreateTaskResultSchema 校验并返回... }
            // 对非任务请求,验证返回值符合 CallToolResultSchema
            const validationResult = parseSchema(CallToolResultSchema, result);
            if (!validationResult.success) {
                throw new ProtocolError(ProtocolErrorCode.InvalidParams,
                    `Invalid tools/call result: ${errorMessage}`);
            }
            return validationResult.data;
        };
        return super.setRequestHandler(method, wrappedHandler);
    }
    return super.setRequestHandler(method, handler);
}

这层防线确保了即使开发者直接使用底层 Server 类注册工具处理器,协议层面的数据完整性也能得到保障。

8.1.2 McpServer 类:开发者友好的门面

McpServer 类定义在 packages/server/src/server/mcp.ts(b8886e7 实测 1329 行),它并不继承 Server,而是组合了一个 Server 实例:

// packages/server/src/server/mcp.ts 第61-77行(节选:省略 _experimental 字段)
export class McpServer {
    public readonly server: Server;

    private _registeredResources: { [uri: string]: RegisteredResource } = {};
    private _registeredResourceTemplates: { [name: string]: RegisteredResourceTemplate } = {};
    private _registeredTools: { [name: string]: RegisteredTool } = {};
    private _registeredPrompts: { [name: string]: RegisteredPrompt } = {};

    constructor(serverInfo: Implementation, options?: ServerOptions) {
        this.server = new Server(serverInfo, options);
    }
}

组合优于继承——这个设计选择使得 McpServer 能够完全控制暴露给开发者的 API 表面,而不必担心 Server 或 Protocol 的内部方法泄漏到公开接口中。同时,开发者仍然可以通过 server.server 属性访问底层 Server 实例,用于发送通知或设置自定义请求处理器等高级操作。

connect() 和 close() 方法直接委托给内部 Server 实例,传输层的绑定完全透明:

// packages/server/src/server/mcp.ts 第107-116行
async connect(transport: Transport): Promise<void> {
    return await this.server.connect(transport);
}

async close(): Promise<void> {
    await this.server.close();
}

8.2 工具注册的完整链路

工具是 MCP 服务端最重要的能力之一。让我们追踪一次 registerTool() 调用的完整执行路径,从开发者的 API 调用一直到请求被处理并返回结果。本节引用的代码全部出自 mcp-ts-sdk/packages/server/src/server/mcp.ts(TypeScript SDK 是 pnpm workspace,McpServer 住在 packages/server 子包里,不要和 packages/core 下的 Protocol 基类混淆)。

sequenceDiagram
    participant Dev as 开发者代码
    participant McpS as McpServer
    participant RegT as _registeredTools
    participant S as Server (Protocol)
    participant Client as MCP 客户端
    Dev->>McpS: registerTool("calc", config, callback)
    McpS->>McpS: 检查名称冲突
    McpS->>McpS: _createRegisteredTool()
    McpS->>McpS: validateAndWarnToolName("calc")
    McpS->>McpS: createToolExecutor(inputSchema, handler)
    McpS->>RegT: 存入 _registeredTools["calc"]
    McpS->>McpS: setToolRequestHandlers() [首次调用]
    McpS->>S: registerCapabilities({ tools: ... })
    McpS->>S: setRequestHandler("tools/list", ...)
    McpS->>S: setRequestHandler("tools/call", ...)
    McpS->>S: sendToolListChanged()
    Note over Client,S: 客户端发起工具调用
    Client->>S: tools/call { name: "calc", arguments: {...} }
    S->>McpS: 调用已注册的 tools/call handler
    McpS->>RegT: 查找 _registeredTools["calc"]
    McpS->>McpS: validateToolInput(tool, args)
    McpS->>McpS: executeToolHandler(tool, args, ctx)
    McpS->>McpS: validateToolOutput(tool, result)
    McpS-->>Client: CallToolResult

8.2.1 注册阶段:从 API 到存储

registerTool() 方法(mcp-ts-sdk/packages/server/src/server/mcp.ts:865-:894)接收三个参数:工具名称、配置对象和回调函数。它首先检查名称是否已被占用,然后委托给内部的 _createRegisteredTool() 方法。(pin 是 2.0.0-alpha 线:v1 里已标 deprecated 的变参重载 server.tool() / .prompt() / .resource() 在这里已经删除,只剩 registerTool / registerPrompt / registerResource,schema 也必须是 z.object({...}) 这类 Standard Schema 对象、不再接受 v1 的"裸 shape"——见仓库 docs/migration.md 第 185 行与第 246 行起的两节。)

// mcp-ts-sdk/packages/server/src/server/mcp.ts:865(节选:省略 cb 的类型断言)
registerTool<OutputArgs extends StandardSchemaWithJSON,
             InputArgs extends StandardSchemaWithJSON | undefined = undefined>(
    name: string,
    config: {
        title?: string;
        description?: string;
        inputSchema?: InputArgs;
        outputSchema?: OutputArgs;
        annotations?: ToolAnnotations;
        _meta?: Record<string, unknown>;
    },
    cb: ToolCallback<InputArgs>
): RegisteredTool {
    if (this._registeredTools[name]) {
        throw new Error(`Tool ${name} is already registered`);
    }

    const { title, description, inputSchema, outputSchema, annotations, _meta } = config;   // :881

    return this._createRegisteredTool(
        name, title, description, inputSchema, outputSchema,
        annotations, { taskSupport: 'forbidden' }, _meta, cb
    );
}

_createRegisteredTool() 方法(mcp-ts-sdk/packages/server/src/server/mcp.ts:767-:837)执行以下关键步骤:

  1. 名称验证:调用 validateAndWarnToolName(name) 检查工具名是否符合规范(mcp-ts-sdk/packages/server/src/server/mcp.ts:779)
  2. 创建执行器:调用 createToolExecutor(inputSchema, handler) 将原始回调包装为统一的执行器接口
  3. 构建注册对象:创建 RegisteredTool 对象,包含 enable()、disable()、remove()、update() 等生命周期方法
  4. 触发首次初始化:调用 setToolRequestHandlers()(mcp-ts-sdk/packages/server/src/server/mcp.ts:120)注册协议级处理器(仅首次执行)
  5. 发送变更通知:调用 sendToolListChanged()(mcp-ts-sdk/packages/server/src/server/mcp.ts:991)通知已连接的客户端(未连接时什么也不做)

最后两步的调用点紧挨在一起,见 mcp-ts-sdk/packages/server/src/server/mcp.ts:833-:834。这条通知默认每注册一次发一条:连接后连续注册 3 个工具,线上就是 3 条 notifications/tools/list_changed。若在构造选项里传 debouncedNotificationMethods: ['notifications/tools/list_changed'],Protocol.notification() 会把同一轮事件循环里不带 params 的同名通知合并成一条(mcp-ts-sdk/packages/core/src/shared/protocol.ts:965-997;在 pin 树本地构建上实跑,3 次注册线上只剩 1 条)。

createToolExecutor 函数(mcp-ts-sdk/packages/server/src/server/mcp.ts:1125-:1154,注意它是模块级函数而非 McpServer 的方法)是一个精巧的适配层,它解决了"有参数"和"无参数"两种工具回调签名的统一调用问题:

// mcp-ts-sdk/packages/server/src/server/mcp.ts:1125
function createToolExecutor(
    inputSchema: StandardSchemaWithJSON | undefined,
    handler: AnyToolHandler<StandardSchemaWithJSON | undefined>
): ToolExecutor {
    const isTaskHandler = 'createTask' in handler;

    if (isTaskHandler) {
        // 任务型工具的执行路径
        const taskHandler = handler as TaskHandlerInternal;
        return async (args, ctx) => {
            if (!ctx.task?.store) {
                throw new Error('No task store provided.');
            }
            const taskCtx: CreateTaskServerContext = { ...ctx, task: { store: ctx.task.store, requestedTtl: ctx.task?.requestedTtl } };
            if (inputSchema) {
                return taskHandler.createTask(args, taskCtx);
            }
            return (taskHandler.createTask as (ctx: CreateTaskServerContext) => ...)(taskCtx);
        };
    }

    if (inputSchema) {
        // 有参数的普通工具: callback(args, ctx)
        const callback = handler as ToolCallbackInternal;
        return async (args, ctx) => callback(args, ctx);
    }

    // 无参数的普通工具: callback(ctx)
    const callback = handler as (ctx: ServerContext) => CallToolResult | Promise<CallToolResult>;
    return async (_args, ctx) => callback(ctx);
}

8.2.2 调用阶段:校验、执行、再校验

当客户端发送 tools/call 请求时,处理流程经过三道关卡。

第一关:输入校验。validateToolInput() 方法(mcp-ts-sdk/packages/server/src/server/mcp.ts:240)使用 Standard Schema 协议验证传入参数:

// mcp-ts-sdk/packages/server/src/server/mcp.ts:240(泛型签名已简化)
private async validateToolInput(tool, args, toolName) {
    if (!tool.inputSchema) {
        return undefined;  // 无 schema 则跳过验证
    }
    const parseResult = await validateStandardSchema(tool.inputSchema, args ?? {});
    if (!parseResult.success) {
        throw new ProtocolError(ProtocolErrorCode.InvalidParams,
            `Input validation error: Invalid arguments for tool ${toolName}: ${parseResult.error}`);
    }
    return parseResult.data;
}

这里使用的是 Standard Schema 协议(调用的 validateStandardSchema 从 packages/core 导入,见 mcp-ts-sdk/packages/server/src/server/mcp.ts:39 与 :252;而非直接绑定 Zod),这意味着开发者可以使用 Zod、Valibot、ArkType 等任何兼容 Standard Schema 的库来定义输入约束。注意这里抛出的 ProtocolError 位于下文所说的 try 块之内,客户端最终收到的不是 -32602 错误响应,而是一条 isError: true 的工具结果,错误文本放在 content 的 text 块里(在 pin 树本地构建上实跑确认);第三关的输出校验错误同理。

第二关:执行处理器。executeToolHandler() 方法(mcp-ts-sdk/packages/server/src/server/mcp.ts:300)通过之前创建的 executor 调用实际回调:

private async executeToolHandler(tool, args, ctx) {
    return tool.executor(args, ctx);
}

第三关:输出校验。validateToolOutput() 方法(mcp-ts-sdk/packages/server/src/server/mcp.ts:266)验证返回结果是否符合 outputSchema:

// mcp-ts-sdk/packages/server/src/server/mcp.ts:266
private async validateToolOutput(tool, result, toolName) {
    if (!tool.outputSchema) return;
    if (!('content' in result)) return;  // CreateTaskResult 不校验
    if (result.isError) return;          // 错误结果不校验

    if (!result.structuredContent) {
        throw new ProtocolError(ProtocolErrorCode.InvalidParams,
            `Output validation error: Tool ${toolName} has an output schema but no structured content was provided`);
    }

    const parseResult = await validateStandardSchema(tool.outputSchema, result.structuredContent);
    if (!parseResult.success) {
        throw new ProtocolError(ProtocolErrorCode.InvalidParams,
            `Output validation error: Invalid structured content for tool ${toolName}: ${parseResult.error}`);
    }
}

异常兜底。tools/call 处理器在查到工具之后的部分被 try-catch 包裹(try 在 mcp-ts-sdk/packages/server/src/server/mcp.ts:170、catch 在同文件 :208),除了 UrlElicitationRequired 这一特殊错误外(放行分支见 mcp-ts-sdk/packages/server/src/server/mcp.ts:209),块内的异常都会被转换为带 isError: true 标记的 CallToolResult,而不是让 JSON-RPC 层面的错误响应直接暴露给客户端。try 之前的两项检查不在此列:工具不存在或已禁用时直接抛 ProtocolError(InvalidParams)(mcp-ts-sdk/packages/server/src/server/mcp.ts:162-168),客户端收到的是 JSON-RPC 错误(实跑:-32602 Tool nope not found)。兜底包装用的是 createToolError:

// mcp-ts-sdk/packages/server/src/server/mcp.ts:225
private createToolError(errorMessage: string): CallToolResult {
    return {
        content: [{ type: 'text', text: errorMessage }],
        isError: true
    };
}

8.2.3 动态管理:enable/disable/update/remove

每个注册对象都内建了完整的生命周期管理能力。以 RegisteredTool 为例(update() 的实现在 mcp-ts-sdk/packages/server/src/server/mcp.ts:798-:830),它支持动态修改名称、描述、Schema、回调等属性,改了输入 Schema 或回调时重建 executor,最后通知客户端。注意改输入 Schema 用的键名是 paramsSchema,不是注册时的 inputSchema(mcp-ts-sdk/packages/server/src/server/mcp.ts:1110):

update: updates => {
    if (updates.name !== undefined && updates.name !== name) {
        validateAndWarnToolName(updates.name);
        delete this._registeredTools[name];
        if (updates.name) this._registeredTools[updates.name] = registeredTool;
    }
    // ...更新各属性...
    if (needsExecutorRegen) {
        registeredTool.executor = createToolExecutor(registeredTool.inputSchema, currentHandler);
    }
    this.sendToolListChanged();  // 通知客户端重新获取工具列表
}

remove() 实际上是 update({ name: null }) 的快捷方式——将 name 设为 null 会从注册表中删除该工具,但不会销毁对象本身,这允许后续通过 update({ name: 'newName' }) 重新注入。但 update 里拿来比较、删除的 name 是闭包里的注册时名字,改名之后不会跟着变——这会导致一个坑,见 §8.8.5 误用 3。

8.3 资源与提示词的注册机制

资源和提示词的注册遵循与工具类似的模式,但各有其独特的设计考量。

8.3.1 资源注册:静态 URI vs 动态模板

registerResource() 方法(packages/server/src/server/mcp.ts 第576-622行)通过方法重载区分两种资源类型:

  • 静态资源:传入字符串 URI,直接存入 _registeredResources 字典
  • 动态资源模板:传入 ResourceTemplate 对象,存入 _registeredResourceTemplates 字典

当客户端发送 resources/read 请求时(packages/server/src/server/mcp.ts 第481-502行),处理器先精确匹配静态资源,未命中则遍历所有模板尝试 URI 模式匹配:

// packages/server/src/server/mcp.ts 第481-502行(节选)
this.server.setRequestHandler('resources/read', async (request, ctx) => {
    const uri = new URL(request.params.uri);
    // 精确匹配
    const resource = this._registeredResources[uri.toString()];
    if (resource) {
        if (!resource.enabled) throw new ProtocolError(...);
        return resource.readCallback(uri, ctx);
    }
    // 模板匹配
    for (const template of Object.values(this._registeredResourceTemplates)) {
        const variables = template.resourceTemplate.uriTemplate.match(uri.toString());
        if (variables) {
            return template.readCallback(uri, variables, ctx);
        }
    }
    throw new ProtocolError(ProtocolErrorCode.ResourceNotFound, `Resource ${uri} not found`);
});

ResourceTemplate 类(packages/server/src/server/mcp.ts 第1021-1063行)封装了 URI 模板和两种可选回调:list(枚举所有匹配资源)和 complete(URI 变量自动补全)。list 回调被显式要求必须声明(即便为 undefined),这是为了避免开发者意外遗漏资源枚举功能。

8.3.2 提示词注册:Schema 驱动的参数处理

提示词注册的特殊之处在于 createPromptHandler() 函数(packages/server/src/server/mcp.ts 第1265-1287行),它创建了一个闭包来封装 Schema 解析和回调调用。与工具不同,prompts/get 处理器外面没有 try-catch 兜底,参数校验失败会直接变成 -32602 的 JSON-RPC 错误:

// packages/server/src/server/mcp.ts 第1265-1287行(参数类型已简化)
function createPromptHandler(name, argsSchema, callback): PromptHandler {
    if (argsSchema) {
        const typedCallback = callback as (args, ctx) => GetPromptResult | Promise<GetPromptResult>;
        return async (args, ctx) => {
            const parseResult = await validateStandardSchema(argsSchema, args);
            if (!parseResult.success) {
                throw new ProtocolError(ProtocolErrorCode.InvalidParams,
                    `Invalid arguments for prompt ${name}: ${parseResult.error}`);
            }
            return typedCallback(parseResult.data, ctx);
        };
    } else {
        const typedCallback = callback as (ctx) => GetPromptResult | Promise<GetPromptResult>;
        return async (_args, ctx) => typedCallback(ctx);
    }
}

这个设计与工具的 createToolExecutor 异曲同工:通过闭包捕获类型信息,调用点就不必再写类型断言(as)。

8.4 自动补全机制 (completable.ts)

自动补全是 MCP 协议中一个提升用户体验的精巧功能。它允许客户端在用户输入提示词参数或资源 URI 变量时,实时获取补全建议。整个机制的实现集中在 completable.ts(74 行)和 McpServer 的补全处理器中。

8.4.1 Completable Schema:用 Symbol 标记可补全字段

completable() 函数(packages/server/src/server/completable.ts 第51-59行)使用 Symbol 属性为 Schema 附加补全元数据,这是一种零侵入的标记方式:

// packages/server/src/server/completable.ts 第3行与第51-59行(省略中间的类型定义与 JSDoc)
export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable');

export function completable<T extends StandardSchemaWithJSON>(
    schema: T,
    complete: CompleteCallback<T>
): CompletableSchema<T> {
    Object.defineProperty(schema as object, COMPLETABLE_SYMBOL, {
        value: { complete } as CompletableMeta<T>,
        enumerable: false,    // 不出现在 JSON 序列化中
        writable: false,      // 不可修改
        configurable: false   // 不可删除
    });
    return schema as CompletableSchema<T>;
}

使用 Symbol.for('mcp.completable') 而非普通 Symbol 是有意为之——Symbol.for 创建的是全局 Symbol,即使跨模块引用也能保持同一性,这对于 Schema 对象可能在不同包之间传递的场景至关重要。

属性被设置为 enumerable: false,这确保了补全元数据不会干扰 Schema 的正常序列化(例如转换为 JSON Schema 时)。

8.4.2 补全请求的处理流程

当 McpServer 检测到注册的提示词或资源模板包含可补全字段时,会自动调用 setCompletionRequestHandler() 启用 completion/complete 请求处理器。处理器根据引用类型分发到不同的处理逻辑:

// packages/server/src/server/mcp.ts 第352-368行(节选:省略两个分支里的 assertCompleteRequest* 调用)
this.server.setRequestHandler('completion/complete', async (request) => {
    switch (request.params.ref.type) {
        case 'ref/prompt':
            return this.handlePromptCompletion(request, request.params.ref);
        case 'ref/resource':
            return this.handleResourceCompletion(request, request.params.ref);
        default:
            throw new ProtocolError(ProtocolErrorCode.InvalidParams, ...);
    }
});

提示词补全的处理过程(packages/server/src/server/mcp.ts 第373-400行)特别值得关注:它需要从 Zod schema 的 shape 中提取出指定参数名对应的字段,解包可能的 Optional 包装,然后检查该字段是否被 completable() 标记过。这条路不走 Standard Schema:getSchemaShape 读的是 schema 对象上的 .shape 属性,unwrapOptionalSchema 读的是 type === 'optional' 与 def.innerType,源码注释写明是按 Zod 对象 schema 设计的(同文件第1307-1329行):

// packages/server/src/server/mcp.ts 第373-400行(节选)
private async handlePromptCompletion(request, ref) {
    const prompt = this._registeredPrompts[ref.name];
    if (!prompt) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} not found`);
    if (!prompt.enabled) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Prompt ${ref.name} disabled`);
    if (!prompt.argsSchema) return EMPTY_COMPLETION_RESULT;

    const promptShape = getSchemaShape(prompt.argsSchema);
    const field = unwrapOptionalSchema(promptShape?.[request.params.argument.name]);
    if (!isCompletable(field)) return EMPTY_COMPLETION_RESULT;

    const completer = getCompleter(field);
    if (!completer) return EMPTY_COMPLETION_RESULT;

    const suggestions = await completer(request.params.argument.value, request.params.context);
    return createCompletionResult(suggestions);
}

补全结果最多返回 100 个建议(packages/server/src/server/mcp.ts 第1289-1298行),超出部分通过 hasMore: true 标记告知客户端存在更多结果:

function createCompletionResult(suggestions: readonly unknown[]): CompleteResult {
    const values = suggestions.map(String).slice(0, 100);
    return {
        completion: {
            values,
            total: suggestions.length,
            hasMore: suggestions.length > 100
        }
    };
}

8.5 传输层绑定:从 McpServer 到网络

McpServer.connect(transport) 的调用链最终抵达 Protocol 基类的 connect() 方法(packages/core/src/shared/protocol.ts 第455-490行)。这个方法执行三个关键操作:

  1. 接管传输层回调:包装 transport 的 onclose、onerror、onmessage 回调(先调用原先挂着的回调,再走 Protocol 自己的逻辑)
  2. 消息路由:根据消息类型(请求/响应/通知)分发到对应的处理器
  3. 启动传输:把支持的协议版本列表交给 transport(供 HTTP 传输校验版本头),再调用 transport.start() 开始接收消息
// packages/core/src/shared/protocol.ts 第455-490行(节选)
async connect(transport: Transport): Promise<void> {
    this._transport = transport;

    this._transport.onclose = () => { /* 先调原 onclose,再清理 */ };
    this._transport.onerror = (error) => { /* 先调原 onerror,再上报 */ };

    const _onmessage = this._transport?.onmessage;
    this._transport.onmessage = (message, extra) => {
        _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: ${JSON.stringify(message)}`));
        }
    };

    transport.setSupportedProtocolVersions?.(this._supportedProtocolVersions);

    await this._transport.start();
}

Server 类覆写了 buildContext() 方法(packages/server/src/server/server.ts 第156-176行),为每个请求构建 ServerContext 对象,包含日志、用户输入获取、采样请求等服务端特有的能力:

// packages/server/src/server/server.ts 第156-176行
protected override buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ServerContext {
    // Only create http when there's actual HTTP transport info or auth info
    const hasHttpInfo = ctx.http || transportInfo?.request || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream;
    return {
        ...ctx,
        mcpReq: {
            ...ctx.mcpReq,
            log: (level, data, logger) => this.sendLoggingMessage({ level, data, logger }),
            elicitInput: (params, options) => this.elicitInput(params, options),
            requestSampling: (params, options) => this.createMessage(params, options)
        },
        http: hasHttpInfo ? {
            ...ctx.http,
            req: transportInfo?.request,
            closeSSE: transportInfo?.closeSSEStream,
            closeStandaloneSSE: transportInfo?.closeStandaloneSSEStream
        } : undefined
    };
}

这意味着在任何工具回调中,开发者都可以通过 ctx.mcpReq.log() 发送日志、通过 ctx.mcpReq.elicitInput() 向用户请求输入,这些能力不是在 connect() 时一次性注入的,而是每个请求到达时由 Protocol._onrequest 调 buildContext() 现场拼进上下文(packages/core/src/shared/protocol.ts:608)。

8.6 框架集成适配器

MCP TypeScript SDK 为 Express、Fastify、Hono 三个 Web 框架提供了开箱即用的适配器,它们位于 packages/middleware/ 目录下(同目录下的第四个包 node 是 Node.js 原生 HTTP 的 Streamable HTTP 传输封装,不属于框架适配,本节不展开)。

graph LR
    subgraph "packages/middleware/"
        Express["express/<br/>createMcpExpressApp()"]
        Fastify["fastify/<br/>createMcpFastifyApp()"]
        Hono["hono/<br/>createMcpHonoApp()"]
    end
    Express -->|"express.json()"| JSON1[JSON Body 解析]
    Fastify -->|"内建 JSON 解析"| JSON2[JSON Body 解析]
    Hono -->|"手动 clone + json()"| JSON3[JSON Body 解析]
    Express -->|"app.use()"| DNS1[DNS 重绑定防护]
    Fastify -->|"addHook('onRequest')"| DNS2[DNS 重绑定防护]
    Hono -->|"app.use('*')"| DNS3[DNS 重绑定防护]
    DNS1 --> HV[hostHeaderValidation]
    DNS2 --> HV
    DNS3 --> HV
    style Express fill:#d4edda
    style Fastify fill:#cce5ff
    style Hono fill:#fff3cd

三个适配器遵循完全一致的设计模式,核心差异仅在于框架特有的中间件注册方式。

8.6.1 Express 适配器

createMcpExpressApp()(packages/middleware/express/src/express.ts 第62-88行)创建预配置的 Express 应用:

export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): Express {
    const { host = '127.0.0.1', allowedHosts, jsonLimit } = options;
    const app = express();
    app.use(express.json(jsonLimit ? { limit: jsonLimit } : undefined));

    if (allowedHosts) {
        app.use(hostHeaderValidation(allowedHosts));
    } else {
        const localhostHosts = ['127.0.0.1', 'localhost', '::1'];
        if (localhostHosts.includes(host)) {
            app.use(localhostHostValidation());
        } else if (host === '0.0.0.0' || host === '::') {
            console.warn();
        }
    }
    return app;
}

8.6.2 Fastify 适配器

Fastify 无需额外的 JSON 解析中间件(框架内建),DNS 防护通过 addHook('onRequest', ...) 注入:

// packages/middleware/fastify/src/fastify.ts 第56-82行(节选)
export function createMcpFastifyApp(options: CreateMcpFastifyAppOptions = {}): FastifyInstance {
    const { host = '127.0.0.1', allowedHosts } = options;
    const app = Fastify();
    // Fastify parses JSON by default - no middleware needed
    if (allowedHosts) {
        app.addHook('onRequest', hostHeaderValidation(allowedHosts));
    } else {
        const localhostHosts = ['127.0.0.1', 'localhost', '::1'];
        if (localhostHosts.includes(host)) {
            app.addHook('onRequest', localhostHostValidation());
        } else if (host === '0.0.0.0' || host === '::') {
            app.log.warn(/* 同样的警告 */);
        }
    }
    return app;
}

8.6.3 Hono 适配器

Hono 适配器最为特殊——它需要手动实现 JSON body 解析,因为 Hono 的 Web Standard API 风格不提供自动解析:

// packages/middleware/hono/src/hono.ts 第47-68行(去掉了注释)
app.use('*', async (c: Context, next) => {
    if (c.get('parsedBody') !== undefined) return await next();
    const ct = c.req.header('content-type') ?? '';
    if (!ct.includes('application/json')) return await next();
    try {
        const parsed = await c.req.raw.clone().json();  // 克隆请求以避免消耗流
        c.set('parsedBody', parsed);
    } catch {
        return c.text('Invalid JSON', 400);
    }
    return await next();
});

注意 c.req.raw.clone().json() 的使用——Hono 基于 Web Standard Request API,请求体是一次性流,必须先 clone() 再读取(源码注释:"so we don't consume the original request stream"),否则原始请求流被读空,后续处理器再拿 c.req.raw 就读不到请求体了。

8.6.4 DNS 重绑定防护

三个适配器共享同一个安全策略:当服务器绑定到 localhost 地址时,自动启用 Host 头验证。核心验证逻辑只有一份,位于 packages/server/src/server/middleware/hostHeaderValidation.ts;三个框架包各自的 packages/middleware/{express,fastify,hono}/src/middleware/hostHeaderValidation.ts 只是薄封装,开头一律 import { localhostAllowedHostnames, validateHostHeader } from '@modelcontextprotocol/server',把结果翻译成各框架的中间件签名:

// packages/server/src/server/middleware/hostHeaderValidation.ts:17(错误对象里的 message 等字段已省略)
export function validateHostHeader(
    hostHeader: string | null | undefined,
    allowedHostnames: string[]
): HostHeaderValidationResult {
    if (!hostHeader) {
        return { ok: false, errorCode: 'missing_host', message: 'Missing Host header' };
    }
    let hostname: string;
    try {
        hostname = new URL(`http://${hostHeader}`).hostname;
    } catch {
        return { ok: false, errorCode: 'invalid_host_header', ... };
    }
    if (!allowedHostnames.includes(hostname)) {
        return { ok: false, errorCode: 'invalid_host', ... };
    }
    return { ok: true, hostname };
}

使用 new URL(http://${hostHeader}).hostname 来解析 Host 头是一个巧妙的做法,它借用 WHATWG 标准的 URL 解析器来正确处理 IPv4、IPv6(如 [::1]:3000)和带端口的主机名,避免了手动解析的复杂性。

8.7 初始化握手与能力协商

在本专栏锚定的 2025-11-25 规范里,MCP 的初始化握手是一个严格的两步过程,完全由 Server 类自动处理。(2026-07-28 版规范已去掉 initialize / notifications/initialized 握手,协议版本与能力改由每个请求的 _meta 携带;pin 版 SDK 实现的最新协议版本仍是 2025-11-25。)

Server 构造函数中注册了 initialize 请求处理器,其实现如下(packages/server/src/server/server.ts 第426-444行):

// packages/server/src/server/server.ts 第426-444行
private async _oninitialize(request: InitializeRequest): Promise<InitializeResult> {
    const requestedVersion = request.params.protocolVersion;
    this._clientCapabilities = request.params.capabilities;
    this._clientVersion = request.params.clientInfo;

    const protocolVersion = this._supportedProtocolVersions.includes(requestedVersion)
        ? requestedVersion
        : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION);

    this.transport?.setProtocolVersion?.(protocolVersion);

    return {
        protocolVersion,
        capabilities: this.getCapabilities(),
        serverInfo: this._serverInfo,
        ...(this._instructions && { instructions: this._instructions })
    };
}

关键设计决策:

  1. 版本协商策略:如果客户端请求的版本在支持列表中,直接使用;否则回退到服务端支持列表的第一项(默认即最新版 2025-11-25),而不是直接拒绝连接。这正是 2025-11-25 规范对服务端的要求:不支持客户端请求的版本时,必须(MUST)回应另一个自己支持的版本,且应该(SHOULD)是自己支持的最新版本;客户端若不支持服务端回的版本,应该(SHOULD)断开。

  2. 能力的延迟注册:McpServer 的 setToolRequestHandlers() 等方法在第一次注册工具/资源/提示词时才调用 registerCapabilities(),而不是在构造函数中就声明所有能力。这意味着如果一个服务端没有注册任何工具,它的能力声明中就不会包含 tools 字段;TS Client 默认配置下的 listTools() 看到对端没声明 tools,会直接返回空列表、不发请求(packages/client/src/client/client.ts:990-995)。

  3. 能力注册的时序约束:registerCapabilities() 方法(packages/server/src/server/server.ts 第211-220行)会检查传输层是否已连接,如果已连接则抛出异常。这确保了能力声明只在初始化之前发生。McpServer 的 setToolRequestHandlers() 有幂等检查(_toolHandlersInitialized 标志),只在第一次注册工具时调 registerCapabilities()——所以只要连接前已经注册过至少一个工具,连接后还能继续注册新工具(此时只更新内部注册表并发 list_changed,不修改已声明的能力);反过来,若连接前一个工具都没注册,连接后的第一次 registerTool() 会直接抛 SdkError(ALREADY_CONNECTED,实跑确认)。资源、提示词同理。

8.8 错误处理的多层防线

MCP TypeScript SDK 的错误处理设计体现了"纵深防御"的思想,从 Schema 验证到协议层,每一层都有独立的错误捕获机制。

Schema 层:validateStandardSchema() 在输入/输出不符合预期时返回 { success: false, error: string },上层将其转换为 ProtocolError。

工具执行层:tools/call 处理器(packages/server/src/server/mcp.ts:161 起)的 try-catch 将工具查找之后的异常包装为 CallToolResult(工具不存在或已禁用则直接回 -32602),只有 UrlElicitationRequired(packages/server/src/server/mcp.ts:209 的分支)被允许直接传播——因为 URL Elicitation 需要客户端执行重定向操作,不能被当作普通的工具执行错误处理。

协议层:Server.setRequestHandler 的覆写(packages/server/src/server/server.ts 第225-272行)对 tools/call 的请求和响应都做 Schema 验证,确保即使开发者直接使用底层 API 也不会发送格式错误的数据。

能力层:assertRequestHandlerCapability()(packages/server/src/server/server.ts 第369-416行)和 assertNotificationCapability()(同文件第307-366行)在注册处理器和发送通知时检查能力声明,在开发阶段就暴露配置错误。

传输层:Protocol 基类的 onmessage 回调对无法识别的消息类型调用 _onerror(),把错误交给用户设置的 onerror 回调(packages/core/src/shared/protocol.ts:482、:524-526);没设 onerror 的话,这条消息仍是被静默丢弃的。

这种多层防线的设计意味着:即使某一层的校验被绕过(例如开发者直接操作底层 Server 类),其他层仍然能够拦截错误。这是构建可靠分布式系统的重要原则。

8.8.1 ts-server 核心文件清单与行数(b8886e7)

先把"server 包到底有多大、每个文件占多少"这件事用真实可复核的行数钉死。以下数据用 wc -l 直接跑在本专栏基准 commit b8886e7 的 packages/server/src/server/ 上,读者 git checkout b8886e7 即可逐条对齐。

文件 行数 核心职责 本章定位
mcp.ts 1329 McpServer 门面类(registerTool/Resource/Prompt、补全分发、能力延迟声明) §8.1.2 / §8.2 / §8.3 主线
server.ts 677 Server extends Protocol(initialize 握手、tools/call 双向校验、ServerContext 构建、能力断言) §8.1.1 / §8.7 主线
streamableHttp.ts 1038 Streamable HTTP 服务端传输(session / SSE / 响应流 / Last-Event-ID 恢复) 第 13 章延伸
stdio.ts 138 StdioServerTransport(stdin/stdout line-delimited JSON) 第 12 章延伸
completable.ts 74 completable() Symbol 标记 + isCompletable / getCompleter 提取器 §8.4 主线

三条值得钉住的物理事实:

  1. streamableHttp.ts 1038 行 / stdio.ts 138 行 ≈ 7.5×——HTTP 传输要管会话路由、SSE 流、响应流收尾、基于 EventStore 的 Last-Event-ID 断线续传,还保留着一套已标 @deprecated 的内置 Host/Origin 校验、透传 authInfo;stdio 只负责按行读写 JSON。网络传输的工程复杂度远超本地 IPC。Python SDK 那边同一对比是 server/streamable_http.py 1056 行 / server/stdio.py 77 行 ≈ 13.7×——两边的 HTTP 传输文件行数相当(1038 对 1056),比例的差别主要来自 stdio 一侧(138 对 77)(另见第 10 章 §10.10.5 的 mcp/server/ 文件清单)。
  2. 本章双层合计 mcp.ts 1329 + server.ts 677 = 2006 行——占 server 包 src/ 源码(不含 .examples.ts,实测 3950 行)约 51%,余下不到一半是传输层与周边。mcp.ts 是 server.ts 的 1.96 倍:门面类承载的注册表、生命周期、补全分发、executor 闭包远多于协议层本身的代码量。
  3. completable.ts 74 行——§8.4 讨论的 completable() 标记只用 74 行就写完了"Symbol 元数据 + 类型守卫 + 取回补全回调";解包 Optional 的 unwrapOptionalSchema 不在这个文件里,而在 mcp.ts 末尾(第1322-1329行)。

8.8.2 Server vs McpServer:两套 API 的能力投影

Server 与 McpServer 并不是"低级 vs 高级"的简单包装关系,它们暴露的 API 在粒度和语义上有根本差异。下表以方法为单位对齐:

关注点 Server(server.ts 677 行) McpServer(mcp.ts 1329 行)
注册接口 setRequestHandler(method, handler) 按 JSON-RPC method 名直接挂钩子 registerTool / registerResource / registerPrompt 按语义注册
参数校验 所有请求先由 Protocol.setRequestHandler 按该 method 的请求 schema 解析(packages/core/src/shared/protocol.ts 第 1008-1019 行);Server 另外只对 tools/call 的请求与响应做 CallToolRequestSchema / CallToolResultSchema(任务路径则是 CreateTaskResultSchema)包裹(packages/server/src/server/server.ts 第 225-272 行) 三段式:validateToolInput → executor → validateToolOutput(§8.2.2),Standard Schema 协议可接 Zod / Valibot / ArkType
能力声明 registerCapabilities({...}) 要求显式传字段,连接后调用直接抛异常 setToolRequestHandlers 等方法在首次注册时才声明能力,内部用 _toolHandlersInitialized 幂等标志规避"连接后注册"限制
生命周期 只处理 initialize + notifications/initialized(构造函数直接挂钩;声明了 logging 能力时另挂 logging/setLevel) RegisteredTool.enable/disable/update/remove 动态管理,update 触发 sendToolListChanged
客户端反向交互 createMessage / elicitInput / listRoots / sendLoggingMessage / sendToolListChanged 等原子方法 只转发 sendLoggingMessage 与三个 send*ListChanged(packages/server/src/server/mcp.ts 第 976-1004 行);createMessage / elicitInput / listRoots 要通过 server.server.xxx 穿透调用;工具回调里走 ctx.mcpReq.log / elicitInput / requestSampling
补全 无——需要开发者先声明 completions 能力,再自己调用 setRequestHandler('completion/complete', ...) completable() 标记 + handlePromptCompletion / handleResourceCompletion 自动分发(§8.4.2)
错误模型 协议级 ProtocolError 原样抛出 tools/call 异常被 createToolError 包装成 { isError: true, content: [...] };仅 UrlElicitationRequired 直通(工具不存在/已禁用时直接回 -32602)
适用场景 需要完全自己掌控请求处理、直接写协议层调试工具;规范之外的自定义 method(如私有扩展 x-company/foo)不能走 setRequestHandler(它只认规范内的 method,按名字取请求 schema,实跑自定义 method 会回 -32603),要挂 Protocol 的 fallbackRequestHandler(packages/core/src/shared/protocol.ts:330) 绝大多数业务开发——工具/资源/提示词三件套

两套 API 的关键不对称:Server 是方法级 API(method 是第一公民),McpServer 是能力级 API(tool / resource / prompt 是第一公民)。这也解释了为什么 McpServer 不用继承——如果继承,setRequestHandler 就会出现在子类公开面上,语义层的 registerTool 和协议层的 setRequestHandler('tools/call', ...) 同时暴露,开发者很容易同时操作两套入口导致状态分裂。组合方案下,底层只通过 .server 属性"主动穿透"才可访问,分层边界清晰。

8.8.3 TS Server(本章)↔ Py Client(第 11 章)对称对比

第 11 章剖析 Python SDK 客户端,已把 src/mcp/client/session.py 等文件钉到行号(见第 11 章 §11.9.3 的源码账本)。把本章 TS Server 与第 11 章的 Py Client 做"语言×角色"交叉对比,能帮助读者理解MCP 协议的双语言实现在哪些地方趋同、在哪些地方分叉:

维度 TS Server(本章) Py Client(第 11 章 §11.9) 趋同 or 分叉
核心类分层 Protocol → Server → McpServer 三层 BaseSession → ClientSession 两层 趋同——都把 JSON-RPC 收发抽离基类
语言原生特性利用 TypeScript 条件类型 + Standard Schema 协议适配多校验库 Python anyio 结构化并发 + TaskGroup + memory stream 分叉——TS 走类型,Py 走并发
初始化握手 Server._oninitialize(packages/server/src/server/server.ts 第 426-444 行)自动回应 ClientSession.initialize 主动发起 + 发送 initialized 通知 趋同——双端各自完成握手的一侧(TS Server 回复 initialize 后并不等 initialized 通知)
版本协商 "尽力兼容":客户端请求的版本不在 SUPPORTED_PROTOCOL_VERSIONS 里就回退到列表首项(即 LATEST_PROTOCOL_VERSION = 2025-11-25) 严格校验——服务端回的版本不在 SUPPORTED_PROTOCOL_VERSIONS 里就 raise RuntimeError(src/mcp/client/session.py:185-186) 分叉——服务端宽松、客户端严格
错误处理哲学 工具执行中的异常包装成 isError: true 不中断会话 协议错误 MCPError(src/mcp/shared/exceptions.py,注意是全大写 MCP)/ 传输错误 anyio.BrokenResourceError 直接传播 分叉——服务端兜底、客户端透传
取消支持 Protocol 基类用 Web 标准 AbortController/AbortSignal:_requestHandlerAbortControllers 按 RequestId 存控制器,收到 notifications/cancelled 就 abort 对应那个 anyio cancel scope + TaskGroup 级联取消 趋同——都对 JSON-RPC notifications/cancelled 响应
补全能力 completable() Symbol 标记 + 服务端主动分发 客户端仅发 completion/complete 请求,收结果渲染 互补——一端标记、一端消费
代码体量 server 包 src/ 实测 3950 行(不含 .examples.ts,见 §8.8.1) mcp/client/ 实测 4517 行,其中 auth/ 占 1500 行(逐文件明细见第 11 章 §11.9.3.3) 客户端总量反而更大——OAuth 几乎占掉三分之一

这张表要传递的工程观念:MCP 协议是双向对称的(服务端可向客户端发起 sampling/createMessage、客户端可向服务端发起 tools/call),但两个 SDK 在相同协议下仍做了语言特色的取舍——TS 用类型系统做规范约束,Py 用结构化并发做生命周期管理;服务端倾向"兜底"(因为要对不可信客户端鲁棒),客户端倾向"透传"(因为上层业务需要感知错误做决策)。

读者若要构建跨语言 MCP 系统(例如 Rust 服务端 + TS 客户端),本节对比可作为"协议理解的参照系"——任何偏离这两端现状的设计决策都需要额外论证。

8.8.4 Server.setRequestHandler 双向校验的边界情况

本章 §8.1.1 已经提到 Server.setRequestHandler 对 tools/call 做了包装,但源码里还有两个易被忽视但会导致 bug 的边界情况(b8886e7 的 packages/server/src/server/server.ts 第 225-272 行):

边界 1:走哪套 result schema 由请求说了算,不看返回值。分支条件读的是请求参数而非返回值——源码里是 const { params } = validatedRequest.data; 之后 if (params.task) { parseSchema(CreateTaskResultSchema, result) … },否则才 parseSchema(CallToolResultSchema, result)。也就是说:只要客户端在 tools/call 里带了 task 参数,服务端返回的就必须是 CreateTaskResult(带 task.taskId);反过来,在低层 Server 上直接注册的处理器若对不带 task 的请求返回了 CreateTaskResult,并不会被拦下:CallToolResultSchema 建在宽松对象(z.looseObject)的 ResultSchema 上,content 又有 .default([])(packages/core/src/types/schemas.ts 第 111、1367-1374 行),于是校验通过,客户端收到的是 {"content":[],"task":{...}} 这样一个"补了空 content 的任务结果"(在 pin 树本地构建上实跑)。排查这类问题要同时看请求里有没有 task 字段——只盯返回值会看不出所以然。(走 McpServer 时,任务型工具被普通调用另有专门处理:taskSupport: 'optional' 的工具被普通调用会由 SDK 自动轮询到完成,'required' 的则返回一条 isError: true 的错误结果(该 ProtocolError 抛在 try 块内),见 packages/server/src/server/mcp.ts 第 183-194 行。)

边界 2:pass-through 模式无法绕过。即使不走 McpServer、直接调 server.server.setRequestHandler('tools/call', fn) 注册"裸" handler,也绕不开这层双向校验:Server.setRequestHandler 的覆写无条件生效——只要 method 等于 tools/call,包装就会套上。做代理(把调用转发给下游 MCP Server)时要记住,下游返回的结果同样会按上面的规则再校验一遍。

8.8.5 McpServer 的三种常见误用与源码级应对

读完前面的源码剖析,以下三类误用值得提前防范,本节给出对应的 SDK 源码定位:

误用 1:在 connect() 之后调用 registerCapabilities。用户代码如 await server.connect(transport); server.server.registerCapabilities({ logging: {} }) 会直接抛 new SdkError(SdkErrorCode.AlreadyConnected, 'Cannot register capabilities after connecting to transport')——见 packages/server/src/server/server.ts 第 211-220 行(注意是 SDK 层的 SdkError,用字符串错误码,不是协议层带数字码的 ProtocolError)。但只要连接前已经注册过至少一个工具,通过 McpServer.registerTool 在连接后继续注册新工具就是合法的,因为 setToolRequestHandlers 内部的 _toolHandlersInitialized 幂等标志确保 capability 只声明一次,之后的注册只更新 _registeredTools 字典并发 notifications/tools/list_changed。这种不对称有它的道理——capability 属于协议握手契约、必须冻结,但工具集合属于运行时状态、允许演进。

误用 2:回调没有把结果放进返回的 Promise。createToolExecutor 中对所有回调统一 async 包裹(packages/server/src/server/mcp.ts 第 1125-1154 行),所以即使开发者的 callback 写成同步函数,SDK 也会通过 async (args, ctx) => callback(args, ctx) 把返回值 Promise 化。但如果 callback 内部用了 setTimeout / setImmediate 这类脱离 Promise 链的异步逻辑,SDK 无法帮你兜底——这些 callback 必须手动返回 new Promise((resolve) => setTimeout(() => resolve({content:[...]}), n)) 才能被正确 await。否则回调实际返回的是 undefined,会被 Server 那层结果校验拦下,客户端收到 -32602 Invalid tools/call result: ... expected object, received undefined(实跑)。

误用 3:用 update({name: ...}) 改名后继续用旧引用。RegisteredTool 的 update 方法(packages/server/src/server/mcp.ts 第 798-830 行)在改名时执行 delete this._registeredTools[name]; if (updates.name) this._registeredTools[updates.name] = registeredTool——注意它把同一个 registeredTool 对象重新挂到新 key 上。也就是说,开发者手里持有的 registeredTool 变量仍然有效,继续调用 .update({ enabled: false }) 仍然生效。但如果开发者之前用 _registeredTools['oldName'] 这种"按 key 取值"的方式拿引用(这种写法本身就不推荐),改名后旧 key 就是 undefined 了。SDK 的 API 鼓励"拿 registerTool 返回值"这种引用语义,而不是"从字典里反向查找"。

这里还有一个源码层面的坑:代码里的 name 是 _createRegisteredTool 的参数,被闭包固定为注册时的名字,改名后不会跟着更新。于是改名只在第一次是干净的:calc 改成 calc2 后再改成 calc3,删的是早已不存在的 calc,calc2 这个 key 留了下来;改过名的工具再调 remove()(即 update({ name: null })),删的同样是 calc,工具仍留在列表里。在 pin 树本地构建上实跑,两次改名后 tools/list 里同时出现 calc2 和 calc3,随后 remove() 也没能把它们删掉。静态资源(按 uri)、资源模板与提示词的 update 写法相同(同文件第 641-644、674-677、721-724 行)。需要改名的工具,稳妥做法是 remove() 掉旧的(在改名之前)再重新 registerTool。

8.8.6 Transport 层与 Server 的协作时序

最后把 §8.5 的传输层绑定和 §8.7 的初始化握手拼成一张端到端时序图,帮助读者把"连接建立 → 初始化 → 工具调用 → 关闭"四个阶段串起来:

sequenceDiagram
    autonumber
    participant App as 应用代码
    participant MS as McpServer
    participant S as Server (Protocol)
    participant T as Transport
    participant C as Client
    App->>MS: new McpServer(info, options)
    App->>MS: registerTool("calc", ...)
    Note over MS,S: setToolRequestHandlers 首次触发<br/>registerCapabilities({tools: {listChanged: true}})
    App->>MS: connect(transport)
    MS->>S: this.server.connect(transport)
    S->>T: transport.start()
    Note over T: start() 开始监听(不向对端发消息)
    C->>T: initialize { protocolVersion, capabilities, clientInfo }
    T->>S: onmessage
    S->>S: _oninitialize(server.ts 第 426-444 行)
    S-->>C: { protocolVersion, capabilities, serverInfo, instructions? }
    C->>T: notifications/initialized
    T->>S: onmessage → 通知处理器
    C->>T: tools/call { name: "calc", arguments: {...} }
    T->>S: onmessage → _onrequest
    S->>S: setRequestHandler 包装:校验 Request Schema
    S->>MS: 调 tools/call handler
    MS->>MS: validateToolInput
    MS->>MS: executeToolHandler
    MS->>MS: validateToolOutput
    MS-->>S: CallToolResult
    S->>S: setRequestHandler 包装:校验 Result Schema
    S-->>C: 响应
    App->>MS: server.close()
    MS->>S: this.server.close()
    S->>T: transport.close()
    Note over T,C: stdio 只摘监听、暂停 stdin,不发消息<br/>HTTP 结束所有 SSE 流

时序图要点:

  1. 第一次 registerTool(以及第一次 registerResource / registerPrompt)必须在 connect 之前完成——否则这次调用会在 registerCapabilities 处抛异常。之后的增量注册走"只更新字典 + 发 listChanged 通知"路径。
  2. initialize 与 notifications/initialized 是两条消息:客户端收到 initialize 响应后必须(MUST)发 notifications/initialized,握手才算完成。但 TS Server 并不等这条通知——Protocol 基类没有任何初始化门控,这期间到达的 tools/call 照常处理。2025-11-25 规范对客户端的约束只到"服务端回应 initialize 之前不应(SHOULD NOT)发 ping 以外的请求"(basic/lifecycle.mdx)。
  3. close() 沿 McpServer → Server → Transport 逐层下沉。服务端的 StdioServerTransport.close() 只摘掉 stdin/stdout 上的监听、在没有其他监听者时暂停 stdin,不向对端发送任何东西(packages/server/src/server/stdio.ts 第 79-101 行);Streamable HTTP 传输则结束所有 SSE 流并清空待回响应(packages/server/src/server/streamableHttp.ts 第 900-915 行)。

这张时序图也能帮读者在写集成测试时定位挂起——由于 TS Server 不等 notifications/initialized,initialize 之后的挂起不会是漏发这条通知造成的;如果在 tools/call 后超时,多半是工具回调返回的 Promise 一直没有 settle。

8.8.7 Standard Schema 协议:为什么 TS SDK 不绑定 Zod

McpServer.validateToolInput 使用的 validateStandardSchema 并不直接依赖 Zod。Standard Schema 是 Zod / Valibot / ArkType 三家作者共同设计的接口规范(github.com/standard-schema/standard-schema;SDK 内置的是 2025 年 1 月的 v1,见 packages/core/src/util/standardSchema.ts 顶部注释),核心约定非常简洁——任何 schema 实现在自己对象上挂一个 ~standard 属性,里面暴露 version / vendor / validate(外加可选的 types)。MCP SDK 做校验时只调 validate(value) 这一个方法,因此校验库可插拔。

为什么这个选择对 MCP 很重要:服务端代码常常需要把工具的 inputSchema 同时"运行时校验"和"序列化为 JSON Schema 给客户端看"。Zod 早期没有 JSON Schema 导出,得靠 zod-to-json-schema 第三方包;Valibot 主打更小的打包体积,ArkType 主打性能。TS SDK 把选择权留给用户——registerTool / registerPrompt 接受的类型是 StandardSchemaWithJSON,定义在 packages/core/src/util/standardSchema.ts:

export interface StandardSchemaWithJSON<Input = unknown, Output = Input> {
    readonly '~standard': StandardSchemaV1.Props<Input, Output> & StandardJSONSchemaV1.Props<Input, Output>;
}

注意它是两个 Props 的交集,而不是在 ~standard 外面另挂属性。StandardJSONSchemaV1.Props 在标准的校验字段之上补的是一个 jsonSchema 转换器:

export interface Converter {
    readonly input: (options: Options) => Record<string, unknown>;
    readonly output: (options: Options) => Record<string, unknown>;
}
export type Target = 'draft-2020-12' | 'draft-07' | 'openapi-3.0' | (object & string);

input / output 分开,是因为一个 schema 的入参形态和出参形态可以不同(带默认值、带 transform 时尤其明显);Target 允许调用方指定要哪个 JSON Schema 方言。这段代码的注释把 SDK 的用法说得很清楚——~standard.jsonSchema 用于在 tools/list 里公布参数形状,~standard.validate 用于在 tools/call 到达时校验入参。按同一段注释,Zod v4、ArkType 和 Valibot(经 @valibot/to-json-schema 的 toStandardJsonSchema)都同时实现了这两个接口。

与 Python SDK 的对称对比(第 10 章 §10.3 讨论过 Pydantic 深度集成):Python SDK 硬依赖 Pydantic v2(pyproject.toml 要求 pydantic>=2.12.0;高层 MCPServer 的工具参数校验走 arg_model.model_validate,见 src/mcp/server/mcpserver/utilities/func_metadata.py:81),没有 Python 版的 Standard Schema 协议。结果就是 Python 用户想换用 attrs / msgspec 这类别的校验库,SDK 没有现成的插口。这也反映了两个生态的文化差异——Python 习惯"官方钦定一个库",TypeScript 习惯"靠接口解耦"。

8.8.8 本章源码账本汇总

把本章引用过的所有源码位置集中列一张表(均为基准 commit b8886e7),读者复核时按图索骥:

文件 行号范围 本章章节 内容
packages/server/src/server/server.ts 99-141 §8.1.1 Server 构造函数、initialize 处理器挂载
packages/server/src/server/server.ts 156-176 §8.5 buildContext 注入 ServerContext
packages/server/src/server/server.ts 211-220 §8.7 / §8.8.5 registerCapabilities 连接后拒绝
packages/server/src/server/server.ts 225-272 §8.1.1 / §8.8.4 setRequestHandler 覆写、tools/call 双向校验
packages/server/src/server/server.ts 307-366 / 369-416 §8.8 assertNotificationCapability / assertRequestHandlerCapability
packages/server/src/server/server.ts 426-444 §8.7 _oninitialize 版本协商
packages/server/src/server/mcp.ts 61-77 §8.1.2 McpServer 组合 Server
packages/server/src/server/mcp.ts 107-116 §8.1.2 connect / close 委托
packages/server/src/server/mcp.ts 120-217 §8.2.2 setToolRequestHandlers(含 tools/call 的 try-catch 兜底)
packages/server/src/server/mcp.ts 225-234 §8.2.2 createToolError
packages/server/src/server/mcp.ts 240-261 §8.2.2 validateToolInput
packages/server/src/server/mcp.ts 266-295 §8.2.2 validateToolOutput
packages/server/src/server/mcp.ts 341-400 §8.4.2 补全请求分发与 handlePromptCompletion
packages/server/src/server/mcp.ts 428-505 §8.3.1 资源处理器注册,含 resources/read 精确+模板匹配(481-502)
packages/server/src/server/mcp.ts 576-622 §8.3.1 registerResource 两个重载与实现
packages/server/src/server/mcp.ts 767-837 §8.2.1 _createRegisteredTool
packages/server/src/server/mcp.ts 865-894 §8.2.1 registerTool
packages/server/src/server/mcp.ts 922-951 §8.3.2 registerPrompt
packages/server/src/server/mcp.ts 1021-1063 §8.3.1 ResourceTemplate 类
packages/server/src/server/mcp.ts 1125-1154 §8.2.1 / §8.8.5 createToolExecutor
packages/server/src/server/mcp.ts 1265-1287 §8.3.2 createPromptHandler
packages/server/src/server/completable.ts 3-59 §8.4.1 COMPLETABLE_SYMBOL + completable()
packages/middleware/express/src/express.ts 62-88 §8.6.1 createMcpExpressApp
packages/middleware/fastify/src/fastify.ts 56-82 §8.6.2 createMcpFastifyApp
packages/middleware/hono/src/hono.ts 41-68 §8.6.3 createMcpHonoApp 的 JSON 解析中间件
packages/core/src/shared/protocol.ts 455-490 §8.5 Protocol.connect 消息路由

若社区后续重构(例如 mcp.ts 拆分)——读者按"方法名"搜索仍可定位(git grep 'registerTool('、git grep 'validateToolInput'),因为本章刻意选用这些命名作为锚点。若整个文件被重命名,则第 8.8.1 节的行数表会一起失效,届时以 SDK CHANGELOG 为准。

8.9 总结

本章深入剖析了 MCP TypeScript SDK 服务端的完整实现。核心要点如下:

双层架构是整个设计的基石。Server 类(继承 Protocol)处理协议层面的一切——初始化握手、能力协商、请求验证、消息路由;McpServer 类通过组合 Server 实例,提供了 registerTool()、registerResource()、registerPrompt() 等直觉式 API,将协议复杂性完全封装在内部。

工具注册的链路从 API 调用开始,经过名称验证、executor 创建、注册表存储、协议处理器注册(首次)、客户端通知,形成一条完整的初始化管线。工具调用则经过输入校验、executor 执行、输出校验的三段式流程,每一段都有独立的错误处理。

自动补全机制通过 Symbol 属性标记实现零侵入的元数据附加,结合 Schema shape 解析和 Optional 解包,为提示词参数和资源 URI 变量提供实时补全建议。

框架集成采用工厂函数模式,为 Express、Fastify、Hono 三大框架提供预配置的应用实例,内建 DNS 重绑定防护和 JSON body 解析,开发者只需关注 MCP 路由的挂载。

错误处理的纵深防御——从 Standard Schema 验证、工具执行异常捕获、协议级 Schema 校验到能力声明检查——让绝大多数异常场景都能落到明确的错误信息上,而不是静默失败。

理解了这些服务端内部机制,你不仅能更高效地使用 SDK 构建 MCP 服务器,更能在遇到问题时快速定位根因——因为你知道每一个请求从进入到返回所经过的每一道关卡。