资讯详情

MCP Server 实现原理详解:从协议握手到工具调用的完整链路拆解

📅 2026/10/8 6:11:32 | 华诺云谱 👁 阅读
MCP Server 实现原理详解:从协议握手到工具调用的完整链路拆解
1. 为什么我要自己拆一遍 MCP Server 的调用链MCP Server 是什么一句话说它是一个把「模型能调用的能力」用统一协议暴露出来的轻量服务。它能让 Claude Desktop、IDE 里的编码助手、命令行 Agent 通过同一套 JSON-RPC 消息去读文件、查数据库、调内部 API。适合谁适合那些不满足于「模型只会聊天」想让模型真正动手干活的开发者。我最初接触 MCP 的时候卡在一个很具体的问题上客户端明明连上了工具列表也拉到了但一调用就报tool not found或者参数对不上。翻文档发现大部分资料只讲「MCP 有资源、工具、提示三种能力」却没人把初始化握手、能力协商、工具注册、调用返回这条链路完整串起来。于是我自己写了一个最小 Server把每一步的报文和状态都打出来才算真正搞明白。这篇就按我实际调试的顺序来先讲清楚协议握手里到底交换了什么再给一份可复制的 Server 骨架配置然后跑一次完整的工具调用最后把几个高频报错对照着排一遍。如果你正打算自建 MCP Server或者调别人的 Server 调不通这篇应该能帮你省掉几个小时的抓包时间。需要说明的是MCP 的传输层可以是 stdio也可以是 HTTPSSE。本文为了让你能直接用 curl 验证走的是 HTTP 这条线stdio 的差异我会在配置章节里点出来。另外模型侧我统一用 TaoToken 的 API 通道来演示这样 Key 和 Base URL 只需要配一份工具调用请求和模型请求能放在同一个环境里跑通。2. MCP Server 初始化握手与能力协商的完整链路2.1 三个角色到底谁在跟谁说话先把角色理清楚不然后面看报文会晕。MCP 是客户端-服务器架构但实际跑起来有三个角色MCP 主机Host是你直接用的那个应用比如 Claude Desktop、某款 IDE、或者你自己写的 Agent 主程序。它负责发起连接、管理会话。MCP 客户端Client是主机内部的一个组件它和 Server 保持 1:1 连接。一个主机可以同时挂多个 Client每个 Client 连一个 Server。MCP Server 就是你要写的那个轻量程序它通过标准协议把资源、工具、提示暴露出去。关键点在于模型本身不直接连 Server。是 Client 先把工具定义拿回来塞进模型的上下文里模型决定调哪个工具后Client 再去向 Server 发起真正的调用。这个「模型只出决策、Client 负责执行」的分工是理解整条链路的核心。2.2 初始化握手initialize 请求里有什么连接建立后第一件事是握手。Client 发一个initialize请求里面带三样东西协议版本、客户端能力capabilities、客户端信息。Server 收到后回一个initialize响应带上自己的协议版本、服务端能力和服务端信息。我用一个精简的请求体来说明结构{ jsonrpc: 2.0, id: 1, method: initialize, params: { protocolVersion: 2024-11-05, capabilities: { roots: { listChanged: true }, sampling: {} }, clientInfo: { name: my-agent-host, version: 0.1.0 } } }Server 的响应大致是这样{ jsonrpc: 2.0, id: 1, result: { protocolVersion: 2024-11-05, capabilities: { tools: { listChanged: true }, resources: { subscribe: false, listChanged: true }, prompts: { listChanged: false } }, serverInfo: { name: aliyun-ecs-mcp, version: 0.1.0 } } }这里有个容易踩的坑协议版本必须双方都认。如果 Client 发的是2024-11-05Server 只支持更老的版本Server 应该回它自己支持的最高版本由 Client 决定是否继续。我实测下来版本不匹配时有些客户端会直接断开且不报明确错误所以调试时先把版本对齐。2.3 能力协商谁声明了什么决定了后面能调什么能力协商不是单独一步它就藏在 initialize 的请求和响应里。Client 声明roots和sampling意思是「我能提供文件系统根目录列表」「我支持让你反过来请求我采样」。Server 声明tools、resources、prompts意思是「我这边有这些能力你可以来发现」。这个声明直接决定了后续流程。比如 Server 没声明toolsClient 就不该发tools/listClient 没声明samplingServer 就不能发sampling/createMessage请求。我在调试时遇到过 Server 声明了 tools 但实际没实现tools/list方法Client 一发发现请求就 404这种就是声明和实现不一致。握手完成后Client 会发一个notifications/initialized通知表示「我准备好了」。注意这是通知没有 id也不需要响应。很多新手会等这个通知的响应结果一直卡住。2.4 工具发现tools/list 返回的元数据长什么样握手之后Client 发tools/list来拿工具清单。Server 返回一个数组每个工具包含 name、description、inputSchema。inputSchema 是 JSON Schema描述参数类型和必填项。{ jsonrpc: 2.0, id: 2, method: tools/list, params: {} }响应{ jsonrpc: 2.0, id: 2, result: { tools: [ { name: aliyun-ecs-describe-instances, description: 查询阿里云 ECS 实例列表, inputSchema: { type: object, properties: { regionId: { type: string, description: 地域 ID }, pageSize: { type: integer, default: 10 } }, required: [regionId] } } ] } }这份元数据会被 Client 拼进给模型的提示里。模型看到 description 和 inputSchema才知道这个工具是干嘛的、要传什么参数。所以 description 写得好不好直接决定模型会不会正确调用。我踩过的坑是 description 写得太笼统模型经常传错参数名后来把每个字段的 description 都补全命中率明显上来了。2.5 工具调用从模型决策到 Server 执行模型决定调用某个工具后Client 发tools/call{ jsonrpc: 2.0, id: 3, method: tools/call, params: { name: aliyun-ecs-describe-instances, arguments: { regionId: cn-hangzhou, pageSize: 5 } } }Server 执行完返回 content 数组{ jsonrpc: 2.0, id: 3, result: { content: [ { type: text, text: {\instances\:[{\id\:\i-bp1xxx\,\status\:\Running\}]} } ], isError: false } }Client 把这个结果再喂回模型模型生成最终回答。整条链路到这里闭环。理解了这个顺序后面配置和排错就有据可依了。3. 可复制的 MCP Server 骨架配置与 TaoToken 接入位置3.1 环境与依赖准备我用的是一台干净的 Linux 机器Node.js 20 和 Python 3.11 都装了。MCP 官方提供了 TypeScript 和 Python 两套 SDK本文骨架用 TypeScript 写因为类型定义全调试时不容易传错字段。初始化项目mkdir mcp-ecs-server cd mcp-ecs-server npm init -y npm install modelcontextprotocol/sdk zod npm install -D typescript tsx types/node npx tsc --inittsconfig.json里把target设成ES2022module设成NodeNextoutDir设成dist。这几项不设对SDK 的 ESM 导入会报错。3.2 Server 骨架stdio 与 HTTP 两种传输先给 stdio 版本这是最常用的Claude Desktop 和多数 IDE 都走这条import { Server } from modelcontextprotocol/sdk/server/index.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import { CallToolRequestSchema, ListToolsRequestSchema, } from modelcontextprotocol/sdk/types.js; const server new Server( { name: aliyun-ecs-mcp, version: 0.1.0 }, { capabilities: { tools: {} } } ); server.setRequestHandler(ListToolsRequestSchema, async () ({ tools: [ { name: aliyun-ecs-describe-instances, description: 查询阿里云 ECS 实例列表, inputSchema: { type: object, properties: { regionId: { type: string, description: 地域 ID如 cn-hangzhou }, pageSize: { type: integer, default: 10 }, }, required: [regionId], }, }, ], })); server.setRequestHandler(CallToolRequestSchema, async (request) { const { name, arguments: args } request.params; if (name ! aliyun-ecs-describe-instances) { throw new Error(Unknown tool: ${name}); } const regionId String(args?.regionId ?? ); const pageSize Number(args?.pageSize ?? 10); // 这里替换成真实的 OpenAPI 调用 return { content: [ { type: text, text: JSON.stringify({ regionId, pageSize, instances: [] }), }, ], }; }); const transport new StdioServerTransport(); await server.connect(transport);如果你要走 HTTPSSE把 transport 换成SSEServerTransport并挂到一个 HTTP 服务上。SDK 里对应的类是SSEServerTransport需要传入 endpoint 路径。两种传输的协议报文完全一致只是承载方式不同。3.3 客户端侧配置把 Server 挂进 Host以 Claude Desktop 为例配置文件在~/Library/Application Support/Claude/claude_desktop_config.jsonmacOS或%APPDATA%\Claude\claude_desktop_config.jsonWindows。写入{ mcpServers: { aliyun-ecs: { command: npx, args: [tsx, /absolute/path/to/mcp-ecs-server/src/index.ts], env: { ALIYUN_ACCESS_KEY_ID: your-key-id, ALIYUN_ACCESS_KEY_SECRET: your-key-secret } } } }注意command和args里的路径必须是绝对路径相对路径在 Host 启动时的工作目录下会找不到。这个坑我踩过日志里只显示spawn ENOENT排查了半天。3.4 TaoToken 接入位置统一 Key 与 Base URL模型侧我统一走 TaoToken 的 API 通道。它的作用是让你用一份 Key 就能访问多个模型工具调用请求和模型请求放在同一个环境变量体系里切换模型时不用改代码。在项目根目录建.envTAOTOKEN_API_KEYsk-你的key TAOTOKEN_BASE_URLhttps://taotoken.net/api然后在调用模型的代码里这样读const apiKey process.env.TAOTOKEN_API_KEY; const baseUrl process.env.TAOTOKEN_BASE_URL; const response await fetch(${baseUrl}/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${apiKey}, }, body: JSON.stringify({ model: claude-sonnet-4-5, messages: [{ role: user, content: 列出杭州地域的 ECS 实例 }], tools: toolDefinitions, }), });Key 的获取入口在控制台的 API Keys 页面模型对话可以在对话页直接验证长期跑编码和 Agent 任务的话用 Coding Plan 更划算。这三者的分工是API Keys 管凭证模型对话管验证Coding Plan 管持续调用。3.5 三件套对齐Base URL、Key、Model ID不管你用哪种客户端接入时都要对齐三件套配置项值说明Base URLhttps://taotoken.net/api不带 UTM直接用于请求API Key控制台生成放在 Authorization 头Model ID如claude-sonnet-4-5按实际可用模型填如果你用的是 Cline 或 CC Switch 这类工具它们的配置界面里同样有这三项。Cline 的 MCP 配置里Server 的启动命令和模型 API 配置是分开的两块别混在一起填。Codex 的auth.json里则要同时写OPENAI_BASE_URL和OPENAI_API_KEY模型 ID 在请求体里指定。4. 一次完整工具调用的验证请求与成功结果4.1 启动 Server 并确认握手先用 stdio 方式手动跑一次确认 Server 能起来npx tsx src/index.ts如果没有任何输出就卡住说明它在等 stdin 输入这是正常的。stdio 模式下 Server 不打印日志到 stdout否则会污染协议报文。要打日志请用console.error。4.2 用 curl 验证 HTTP 传输如果你走的是 HTTPSSE可以直接用 curl 验证。先发 initializecurl -X POST http://localhost:3000/mcp \ -H Content-Type: application/json \ -d { jsonrpc: 2.0, id: 1, method: initialize, params: { protocolVersion: 2024-11-05, capabilities: {}, clientInfo: { name: curl-test, version: 0.1.0 } } }预期返回里能看到serverInfo和capabilities.tools。接着发tools/listcurl -X POST http://localhost:3000/mcp \ -H Content-Type: application/json \ -d {jsonrpc:2.0,id:2,method:tools/list,params:{}}返回的 tools 数组里应该有aliyun-ecs-describe-instances。4.3 发起一次真实工具调用curl -X POST http://localhost:3000/mcp \ -H Content-Type: application/json \ -d { jsonrpc: 2.0, id: 3, method: tools/call, params: { name: aliyun-ecs-describe-instances, arguments: { regionId: cn-hangzhou, pageSize: 5 } } }成功时返回{ jsonrpc: 2.0, id: 3, result: { content: [{ type: text, text: {\regionId\:\cn-hangzhou\,\pageSize\:5,\instances\:[]} }], isError: false } }看到isError: false且 content 里有文本就说明整条链路通了。4.4 把模型接进来跑端到端最后一步是把模型接进来。用 TaoToken 的 API 发一个带 tools 的请求模型会返回一个tool_calls字段里面包含工具名和参数。你的 Client 拿到后转成tools/call发给 Server再把结果回填给模型。这一步跑通端到端就完成了。我实测下来最容易出问题的是参数类型。模型有时会把pageSize传成字符串5而 inputSchema 声明的是 integer。Server 侧最好做一次类型转换别直接信任模型传来的类型。5. 高频报错对照排查401、local proxy failed、reading choices、OAuth5.1 401 Unauthorized这个最常见分两种。一种是模型侧 401说明 TaoToken 的 Key 没配对检查.env里的TAOTOKEN_API_KEY是否有多余空格Authorization 头是不是Bearer开头。另一种是 Server 侧 401说明 Server 自己调用的下游 API比如阿里云 OpenAPI认证失败检查 AccessKey 和地域是否匹配。5.2 local proxy failed这个报错通常出现在 Client 连 Server 的时候意思是本地进程启动失败。原因一般是command路径不对或者npx找不到。解决办法是把command换成绝对路径比如/usr/local/bin/nodeargs里用绝对路径指向编译后的dist/index.js。另外确认 Server 进程有执行权限。5.3 reading choices 相关报错这个报错一般出现在解析模型响应时说明返回体里没有choices字段。常见原因是 Base URL 配错了请求打到了非兼容端点。确认TAOTOKEN_BASE_URL是https://taotoken.net/api且请求路径是/v1/chat/completions。如果返回的是 HTML 或错误页也会导致解析失败。5.4 OAuth 相关报错有些 MCP Server 需要 OAuth 授权才能访问下游资源。报错通常提示invalid_token或authorization required。这时候要检查 token 是否过期以及 Server 的 OAuth 配置里回调地址是否和 Client 注册的一致。如果是本地调试可以先用长期有效的 API Key 绕过 OAuth等链路通了再补授权流程。5.5 工具调用返回 isError: true这个不是协议错误是工具执行本身失败。看 content 里的 text通常会有具体原因比如参数缺失、下游超时。我的做法是在 Server 的CallToolRequestSchema处理函数里包一层 try/catch把错误信息原样返回而不是抛异常。抛异常会导致 Client 收到协议级错误反而看不到细节。6. 把这条链路用起来从调试到长期运行跑通一次调用只是开始。真正用起来你会遇到工具越来越多、Server 需要热更新、多个 Client 同时连的情况。我的建议是先把工具注册做成可插拔的每个工具一个文件启动时扫描目录自动注册。这样加工具不用改主逻辑。另外工具调用的日志一定要打全包括请求参数、执行耗时、返回大小。我试过在 Server 里加一个简单的日志中间件把每次tools/call的入参和出参写到文件里排查问题时直接翻日志比抓包快得多。模型侧的统一通道建议固定下来。TaoToken 的 API 通道把 Key 和 Base URL 收敛成一份配置切换模型时只改 Model ID工具定义和调用逻辑都不用动。长期跑编码或 Agent 任务的话Coding Plan 的额度模型更适合持续调用不用每次担心额度。最后提醒一句MCP Server 的能力声明要和实现严格对齐。声明了 tools 就必须实现tools/list和tools/call声明了 resources 就要实现对应的读取方法。声明和实现不一致是调试时最隐蔽的坑。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑