ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

三十行代码跑通MCP Server:从握手到工具调用

三十行代码跑通MCP Server:从握手到工具调用 1. 先搞清楚你写的到底是个什么玩意儿最近 AI 编程工具里全是 MCP 的身影Figma MCP、Playwright MCP、Blender MCP、蓝湖 MCP铺天盖地。很多人的用法就是复制一行npx或者填一个 URL然后就等着 AI 帮你连上外部服务。可一旦你没有现成的 server 可用、想给自己的内部系统接一个工具时就不得不面对一个问题MCP server 到底是怎么跑起来的我最初的想法很简单——写一个最简的 MCP server能验证握手和工具调用这两条核心链路就够了。结果发现网上教程两极分化要么上来就摆一大堆工程目录、Docker 部署、鉴权看得人头晕要么只讲概念完全没有能直接跑通的代码。所以我决定自己做一遍用最少量的代码把一个 MCP server 从握手跑到工具调用跑通把里面最关键的东西拆开揉碎讲清楚。先说结论MCP server 本身没有你想的那么复杂。剥掉所有花架子它做的事情无非就是通过标准输入输出stdio或者 HTTP 通道按 JSON-RPC 2.0 的格式接收请求、返回响应。中间有两个必须走通的环节——握手initialize和工具调用tools/call。这两个机制搞明白剩下的都是填空。这篇文章适合三类人看。第一类是想在 Cursor、Trae、Windsurf 这类工具里配置自定义 MCP server 的开发者第二类是后端工程师想给自己团队的内部系统快速接一层 AI 工具能力第三类是纯粹好奇 MCP 底层长什么样的同学。我会用一个基于官方 TypeScript SDK 的最小实现来讲代码量控制在三十行左右每一行的作用都讲清楚。2. MCP 的传输层stdio 通道与 JSON-RPC 的约定以及它和三次握手的关系很多人在搜MCP 握手的时候会搜到 TCP 三次握手的内容然后一脸懵。这两种握手确实不是一个层面的东西——TCP 三次握手是建立网络连接用的MCP 的 initialize 握手是应用层协议协商用的。但它们的本质逻辑相似连接双方通过几轮消息交换确认你是谁、你能干什么、我们按什么规则聊天。MCP 协议在传输层有两种主流方式。一种是stdio也就是客户端把你的 server 作为子进程启动通过标准输入输出通信另一种是 HTTP 通道server 暴露一个 HTTP 端点客户端通过 HTTP 请求来调用。早期版本主要支持stdio后来的协议版本补充了 Streamable HTTP。对于最简单的本地工具场景stdio是最直接、依赖最少的方案也是我这次实现的选择。用stdio有几个好处。第一不需要处理网络端口、防火墙、CORS 之类的问题第二子进程生命周期跟随 IDE 或者客户端客户端退出进程就结束不会留下僵尸服务第三调试方便你甚至可以在终端里手动往 stdin 敲 JSON 消息来模拟客户端。缺点也很明显——只能本机用不能跨机器调用。远程场景选 HTTP 就对了。再说 JSON-RPC 2.0。这个协议格式很简单请求长这样{jsonrpc: 2.0, id: 1, method: initialize, params: {...}}响应长这样{jsonrpc: 2.0, id: 1, result: {...}}也有不需要响应的事件通知比如 initialized只发请求不要求回复。MCP 的所有交互都是建立在这套消息格式之上的服务器实现的核心工作就是解析这些 JSON 消息、分发给对应方法、返回正确格式的结果。MCP 的握手环节标准流程是客户端发送initialize请求带上自己支持的协议版本列表、客户端信息和能力声明server 收到后回应自己的协议版本、server 信息、能力声明客户端发送initialized通知表示握手完成之后才是正常的业务消息这里有一个新手特别容易踩的坑MCP server 不能只等 initialize 就开始干活必须等客户端发来 initialized 通知后才能处理后续的tools/list、tools/call等请求。官方的 SDK 内部其实已经处理了这个时序但如果你自己裸写 JSON-RPC 处理逻辑就很容易忽略这个约束。我把这个过程类比成两个人第一次见面握手initialize相当于你先自报家门我是谁我说什么语言版本对方回应我知道你是谁了我也告诉你我是谁然后你再说一句好那我们开始聊正事吧initialized。少了最后那句后面的对话就会卡壳。3. 三十行代码逐段拆解从 Server 实例到工具注册的全过程我这次选择用 TypeScript 和官方modelcontextprotocol/sdk原因很简单官方 SDK 把协议细节封装得很干净代码量能压到最低同时完全符合标准协议不会自己造轮子。下面这版代码是我整理后的最小实现用来给 AI 客户端提供一个获取当前时间的工具get_current_time完整代码就三十行出头。import { McpServer } from modelcontextprotocol/sdk/server/mcp.js; import { StdioServerTransport } from modelcontextprotocol/sdk/server/stdio.js; import { z } from zod; const server new McpServer({ name: time-tool-server, version: 1.0.0, }); server.tool( get_current_time, { timezone: z.string().optional().describe(时区如 Asia/Shanghai) }, async ({ timezone }) { const now timezone ? new Date().toLocaleString(zh-CN, { timeZone: timezone }) : new Date().toLocaleString(); return { content: [{ type: text, text: now }] }; } ); const transport new StdioServerTransport(); await server.connect(transport);这段代码看起来很短但每一块的职责我要讲清楚不然你改起来不知道往哪下手。new McpServer(...)创建了核心服务实例。这里填的name和version会出现在 initialize 握手的响应里客户端比如 IDE会把它展示成已连接的工具服务名称。你可以理解成这是你 server 的身份证客户端第一次跟你握手时就会看到这两个字段。server.tool(name, schema, handler)是注册工具的核心方法。第一个参数是工具名后续客户端要用tools/call并且带上这个工具名来调用。第二个参数是参数模式schema我这里用了 zod 来定义参数结构——timezone是可选字符串还加了描述信息。这些描述会被tools/list返回给客户端AI 模型会依据这些描述来理解这个工具有什么参数、需要传什么。你写工具时参数描述写得越清楚AI 的调用准确率越高。第三个参数是异步处理函数真正执行工具逻辑的地方。处理函数里我根据是否传入 timezone 来格式化当前时间然后返回一个固定格式的对象{ content: [{ type: text, text: ... }] }。这个 format 是 MCP 协议规定好的工具调用的返回结果必须打包在content数组里每项必须有type字段文本类型就写text。后面我会专门讲这个格式的坑。new StdioServerTransport()创建 stdio 传输层它做的事情是监听 process.stdin 的数据按行解析 JSON再把响应的 JSON 写回 process.stdout。最后await server.connect(transport)把服务实例和传输层绑定启动消息循环。到这里整个 server 就跑起来了。如果要用 Python 生态官方mcp库的写法也类似from mcp.server.fastmcp import FastMCP mcp FastMCP(time-tool-server) mcp.tool() def get_current_time(timezone: str | None None) - str: import datetime from zoneinfo import ZoneInfo if timezone: return datetime.datetime.now(ZoneInfo(timezone)).strftime(%Y-%m-%d %H:%M:%S) return datetime.datetime.now().strftime(%Y-%m-%d %H:%M:%S) if __name__ __main__: mcp.run()相比之下 Python 的装饰器写法更简洁TypeScript 版本在类型提示上更严格。两种选哪个看你主技术栈不用纠结。4. 跑通之后的本质一次完整工具调用背后的消息流转与内容块格式代码跑通之后你会发现它在终端里没有任何输出看起来像卡住了。别慌这是正常的——它在等待客户端通过 stdin 输入 JSON-RPC 消息。搞清楚这一点最好的办法是我手动模拟客户端把整个流程的消息逐条发一遍你就能看到协议的全貌。完整流程开始。第一步握手客户端发送{jsonrpc:2.0,id:1,method:initialize,params:{protocolVersion:2024-11-05,capabilities:{},clientInfo:{name:curl-test,version:1.0}}}Server 返回{jsonrpc:2.0,id:1,result:{protocolVersion:2024-11-05,capabilities:{tools:{}},serverInfo:{name:time-tool-server,version:1.0.0}}}注意这里 server 返回的capabilities字段里有tools: {}表示这个 server 支持工具调用能力。如果你后续扩展了资源resources或者提示词prompts能力也是在capabilities这里声明。第二步客户端发送 initialized 通知不需要 id不需要响应{jsonrpc:2.0,method:notifications/initialized}第三步客户端请求工具列表{jsonrpc:2.0,id:2,method:tools/list,params:{}}Server 返回{jsonrpc:2.0,id:2,result:{tools:[{name:get_current_time,description:,inputSchema:{type:object,properties:{timezone:{description:时区如 Asia/Shanghai,type:string}},additionalProperties:false,$schema:http://json-schema.org/draft-07/schema#}]}}第四步客户端调用工具{jsonrpc:2.0,id:3,method:tools/call,params:{name:get_current_time,arguments:{timezone:Asia/Shanghai}}}Server 返回{jsonrpc:2.0,id:3,result:{content:[{type:text,text:2025/1/18 14:30:25}]}}到这一步从握手跑到工具调用就算真正完整了。重点来说说content 内容块的格式。这是新手最容易搞错的地方我当初也卡了很久。MCP 协议规定tools/call的返回结果必须是一个对象其中content字段是一个数组数组里的每一项是一个内容块。内容块的type字段可以是text文本、image图片、audio音频、resource资源引用等。如果你的工具返回纯文本就写{ type: text, text: xxx }并且要放在数组里。很多人在第一步会犯的错误是直接返回{ content: hello }或者{ result: hello }这都不符合协议格式。客户端解析不到content数组就会报调用失败。还有一点如果工具执行过程中出错你依然要返回 200 的结果但要在结果里加上isError: true字段这样客户端会把结果视作执行失败并告知 AI 模型。举个例子{jsonrpc:2.0,id:4,result:{content:[{type:text,text:时区无效: Invalid timezone}],isError:true}}为什么协议要强制内容块数组而不是直接返回一个字符串我的理解是MCP 的返回信息可能同时包含文本、截图如果工具操作了浏览器或设计软件、或者资源文件的链接单一类型表达不了这些混合内容。数组的设计让一个工具调用能返回多媒体组合结果这比普通的 REST API 返回更灵活代价就是格式上多了一层包裹。还有一个和工具调用相关的细节值得注意工具执行的耗时是没上限的。协议层面没有超时设计但实际客户端通常有自己的超时策略。如果你的工具要跑一个耗时的任务比如爬取几十个网页最好在里面加上进度通知或者直接做成异步任务返回 taskId避免客户端等待超时误判失败。这个我在后面扩展部分细说。5. 验证与踩坑MCP Inspector、stdout 污染和协议版本这些绕不过去的坎写完了 server接下来是怎么验证、怎么调试。官方提供的最强工具就是 MCP Inspector一条命令就能启动基于网页的可视化调试面板npx modelcontextprotocol/inspector node dist/index.js启动之后Inspector 会连接你的 server自动执行握手然后左侧面板会显示已注册的 tools 列表。你可以点工具、填参数、手动调用右侧会展示每次调用的完整 JSON-RPC 请求和响应。这个工具的好处是省去了手写 JSON 的麻烦还能看到协议层的每一个细节。调试协议类代码强烈建议优先用这种可视化工具而不是自己写测试脚本。我在实测过程中踩了不少坑挑几个值得提醒的分享。第一个坑是stdout 污染。我最初为了调试方便在工具处理函数里加了一句console.log(time tool called)。结果发现客户端连接直接失败或者返回内容解析异常。原因很简单stdio传输模式下stdout 是 MCP 协议消息专用的通道你往 stdout 里打印任何东西都会和 JSON-RPC 消息混在一起客户端解析就崩了。正确做法是调试日志一律写到 stderrconsole.error或者直接写到日志文件。MCP SDK 还提供了标准化的 logging 能力mcp.log客户端可以查收 server 端的日志不过最简实现里用console.error就够用了。第二个坑是协议版本匹配。MCP 协议版本是日期形式的比如2024-11-05、2025-03-26、2025-06-18。客户端在initialize请求里会带上它支持的协议版本列表server 要选一个双方都兼容的版本回应。用官方最新版 SDK 通常不会有问题但如果你发现手写协议响应时客户端老是提示版本不兼容八成是返回的protocolVersion不在客户端支持范围内。这时候要么升级 SDK要么在实现里做协议版本协商逻辑。第三个坑是生命周期时序。我之前提到过 initialized 通知这里展开一下。有次我把工具调用请求直接写在 initialize 响应之后发给 server结果消息石沉大海。查了源码才发现 SDK 在没收到notifications/initialized之前不会处理tools/call请求。这是协议层的有意设计确保客户端真的初始化完成了才允许业务请求进入。如果你自己实现协议解析务必把这三个状态分清楚——等待握手、握手完成等待 initialized、就绪。第四个坑是参数校验和错误返回。工具处理函数里如果直接抛异常SDK 虽然会捕获并返回 isError 结果但如果你希望给 AI 模型更友好的错误描述最好在函数内部 catch 并返回结构化错误内容。比如我上面例子里的 timeout 处理就返回了说明文字加上isError: true。AI 模型看到错误文本后可以根据描述自行调整调用参数——这算是我实测下来提升 AI 工具调用稳定性的一个有效技巧。第五个坑属于日志洁癖范畴正式接入客户端之前一定要检查你的 server 有没有任何外部库往 stdout 打日志。比如某些依赖库在初始化时会输出 banner这些都会污染协议流。最稳妥的方案是在连接传输层前后写一段自动化测试模拟客户端握手并验证返回的是标准 JSON-RPC 消息。6. 从玩具到生产远程 HTTP、资源能力、鉴权与长耗时任务的扩展思路三十行代码能跑通但不代表它能直接支撑生产环境。如果你的目标是让团队内多人共用或者接入远程服务有几个位置必须升级。我这里给你一条清晰的升级路径。第一个升级点是传输层从 stdio 换到 HTTP。官方 SDK 提供了StreamableHTTPServerTransport只需要几十行代码就能把 server 从本地进程变成一个 HTTP 端点。启动后用npx modelcontextprotocol/inspector --transport http http://localhost:3000/mcp就能调试。HTTP 模式需要处理的事情多一点端口监听、CORS 请求、会话管理和并发请求。好消息是 SDK 把这些都封装好了你只需要实现一个 Express 或原生node:http路由把请求转发给 transport。第二个升级点是资源Resources能力。MCP 除了工具调用还有一个非常实用的能力叫 Resources本质上就是暴露一些只读的内容给客户端让 AI 模型在回答问题时能引用这些内容。比如你可以暴露一个数据库 schema 文档资源、一个项目说明文件、一个配置文件模板。实现方式和注册工具类似server.registerResource( config://app-settings, 应用设置, async (uri) ({ contents: [{ uri: uri.href, mimeType: application/json, text: JSON.stringify({ theme: dark, language: zh-CN }), }], }) );配置好之后在 IDE 里你就多了一个-mention 资源的能力可以直接把资源内容注入到对话上下文里。这个能力常常被忽略但它对于让 AI 理解项目背景信息非常有用强烈建议加上。第三个升级点是鉴权与安全。本地 stdio 模式没有安全问题但变成 HTTP 之后就暴露在网络上必须加鉴权。MCP 官方推荐的是 OAuth 2.1 授权流程但最简方案也可以先用 API Key 中间件做一版——请求必须带Authorization: Bearer token才放行。用工具反而不需要太担心这个问题但如果你是给公司内部系统接入口鉴权这关躲不掉。第四个升级点是长耗时任务的处理。我前面提到工具调用没有协议级超时但客户端会有。处理耗时操作标准做法是让它变成提交 查询两步submit_job工具返回一个 jobIdget_job_result工具用来查询结果。这样每次调用都能在几秒内返回不会触发客户端超时。MCP 协议还有进度通知机制notifications/progress工具在运行中可以主动上报进度百分比客户端 UI 会显示进度条。如果你的工具经常跑几十秒的任务这个机制值得研究。还有一个常见的真实需求通过 IDE 配置使用自定义 server。在 Cursor 的 MCP 配置里加一个 command 类型的服务指向你编译后的启动命令{ mcpServers: { time-tool: { command: node, args: [/absolute/path/to/dist/index.js] } } }然后在 Trae、Cline 这类支持 MCP 配置的工具里直接填写这个 server 的启动命令即可。很多工具叫法不一样有的叫 MCP Marketplace有的叫 Add MCP Server配置的本质都是让客户端知道怎么启动你的 server。配完之后AI 在对话中会自动感知到你注册的工具在需要获取时间时主动调用。最后说一句我自己的体会第一版 MCP server 真的不用想太多工具能跑、握手能通、调用能得到结果这三条链路通了整个 MCP 的核心原理你就已经掌握了七八成。后面加资源、加鉴权、换传输层都是水到渠成的事情。反而是那些一上来就铺大工程、把代码堆到上千行的做法会让你更难看清协议的本质。三十行的价值不在于少而在于它把 MCP 必须存在的部分全部保留了下来不多不少。
返回列表