第 2 篇:手写一个 MCP Server——从零到跑通 第 2 篇手写一个 MCP Server——从零到跑通上一篇讲了 MCP 的概念这一篇直接上手。我们用 Node.js 写一个能用的 MCP 服务器——它能查询天气。目标很简单写完之后任何支持 MCP 的 AI 客户端都可以通过这个 Server 获取天气信息。2.1 准备工作你需要装好Node.js一个趁手的编辑器然后建个目录初始化项目mkdirmcp-weather-servercdmcp-weather-servernpminit-y安装 MCP SDKnpminstallmodelcontextprotocol/sdkSDK 帮我们处理了底层的 JSON-RPC 通信、传输层、协议握手。我们只需要关心业务逻辑。2.2 实现一个最简单的 Server在项目根目录下新建index.jsimport{Server}frommodelcontextprotocol/sdk/server/index.js;import{StdioServerTransport}frommodelcontextprotocol/sdk/server/stdio.js;import{CallToolRequestSchema,ListToolsRequestSchema,}frommodelcontextprotocol/sdk/types.js;// 1. 创建一个 Server 实例constservernewServer({name:weather-server,version:1.0.0,},{capabilities:{tools:{},// 声明这个 Server 支持 Tools},},);// 2. 定义 Tools 的处理逻辑server.setRequestHandler(CallToolRequestSchema,async(request){const{name,arguments:args}request.params;if(nameget_weather){constcityargs?.city||深圳;// 模拟天气数据constweatherData{city:city,temperature:Math.floor(Math.random()*15)20,condition:晴,humidity:Math.floor(Math.random()*30)50,};return{content:[{type:text,text:JSON.stringify(weatherData,null,2),},],};}thrownewError(未知工具:${name});});// 3. 定义 Server 暴露了哪些工具server.setRequestHandler(ListToolsRequestSchema,async(){return{tools:[{name:get_weather,description:查询指定城市的天气,inputSchema:{type:object,properties:{city:{type:string,description:城市名称如 北京、上海、深圳,},},required:[city],},},],};});// 4. 启动 Server通过 stdio 传输consttransportnewStdioServerTransport();awaitserver.connect(transport);代码很简单核心就四件事创建 Server 实例——告诉客户端我是谁、我能做什么。注册tools/list——客户端问我你有什么工具“我回答有一个 get_weather 工具”。注册tools/call——客户端说调用一下 get_weather参数是 city北京我执行查询并返回结果。连接传输层——这里用 stdio就是通过标准输入输出通信。在package.json里加一行type: module或者把文件后缀改成.mjs然后跑一下node./index.js# 启动 mcp service跑起来之后没有任何输出正常。因为它现在通过 stdio 通信不是输出到终端。2.3 在终端手敲 JSON-RPC 调试Server 跑起来没输出怎么知道它工作正常直接打开终端跟它对话。MCP 底层用的是JSON-RPC协议所有消息都是 JSON 格式。我们可以手动输入消息来走一遍完整流程。先启动 Server它会在后台等待输入node./index.js看不到输出对因为它正在 stdin 上等着你发消息过去。现在你在终端里输入逐行复制每行是一条消息第一步初始化握手{jsonrpc:2.0,id:1,method:initialize,params:{protocolVersion:2024-11-05,capabilities:{},clientInfo:{name:LJ-agent,version:0.5.0}}}Server 会返回{result:{protocolVersion:2024-11-05,capabilities:{tools:{}},serverInfo:{name:weather-server,version:1.0.0}},jsonrpc:2.0,id:1}{jsonrpc:2.0,method:notifications/initialized}第二步通知初始化完成{jsonrpc:2.0,method:notifications/initialized}通知不需要返回。第三步问 Server 有什么工具{jsonrpc:2.0,id:2,method:tools/list,params:{}}返回{result:{tools:[{name:get_weather,description:查询指定城市的实时天气,inputSchema:{type:object,properties:{city:{type:string,description:城市名称如 北京}},required:[city]}}]},jsonrpc:2.0,id:2}第四步调用工具查天气{jsonrpc:2.0,id:3,method:tools/call,params:{name:get_weather,arguments:{city:北京}}}返回{result:{content:[{type:text,text:城市: 北京\n温度: 22°C\n体感温度: 25°C\n天气: Sunny\n湿度: 69%\n风速: 4 km/h}]},jsonrpc:2.0,id:3}大功告成。整个过程你看到的就是 MCP 协议的真面目——全是简单的 JSON 消息往来。注意每次粘贴后按回车消息才会发送给 Server。id 字段是请求的唯一标识响应里会带回同样的 id方便你匹配谁是谁。以后你有新工具了用这种方式就能快速验证——tools/list看工具列表tools/call调工具。比开浏览器快得多。2.4 接入真实天气 API刚才的代码返回的是随机数太假了。我们接入一个真实的天气 API 看看。这里以 wttr.in 为例——它是一个免费的命令行天气查询服务不需要 API Key。import{Server}frommodelcontextprotocol/sdk/server/index.js;import{StdioServerTransport}frommodelcontextprotocol/sdk/server/stdio.js;import{CallToolRequestSchema,ListToolsRequestSchema,}frommodelcontextprotocol/sdk/types.js;constservernewServer({name:weather-server,version:1.0.0,},{capabilities:{tools:{}}},);server.setRequestHandler(ListToolsRequestSchema,async()({tools:[{name:get_weather,description:查询指定城市的实时天气,inputSchema:{type:object,properties:{city:{type:string,description:城市名称如 北京},},required:[city],},},],}));server.setRequestHandler(CallToolRequestSchema,async(request){const{name,arguments:args}request.params;if(nameget_weather){constcityargs?.city||深圳;// 调用 wttr.in APIconstresawaitfetch(https://wttr.in/${city}?formatj1);constdataawaitres.json();constcurrentdata.current_condition[0];return{content:[{type:text,text:[城市:${city},温度:${current.temp_C}°C,体感温度:${current.FeelsLikeC}°C,天气:${current.weatherDesc[0].value},湿度:${current.humidity}%,风速:${current.windspeedKmph}km/h,].join(\n),},],};}thrownewError(未知工具:${name});});consttransportnewStdioServerTransport();awaitserver.connect(transport);注意这段需要 Node.js v18 才支持fetch。如果版本低可以用node-fetch包。现在你调用get_weather就能拿到真实的天气数据了。2.5 简化写法McpServer Zod2.2 的代码还有一个更清爽的写法——MCP SDK 提供了更高层的McpServer类配合 Zod 做参数校验代码可以写得更简洁。先安装 Zodnpminstallzod然后用McpServer重写index.jsimport{McpServer,StdioServerTransport,}frommodelcontextprotocol/sdk/server/mcp.js;import{z}fromzod;constservernewMcpServer({name:weather-server,version:1.0.0,});server.registerTool(get_weather,{description:查询指定城市的实时天气,inputSchema:z.object({city:z.string().describe(城市名称如 北京),}),},async({city}){constresawaitfetch(https://wttr.in/${city}?formatj1);constdataawaitres.json();constcurrentdata.current_condition[0];return{content:[{type:text,text:[城市:${city},温度:${current.temp_C}°C,体感温度:${current.FeelsLikeC}°C,天气:${current.weatherDesc[0].value},湿度:${current.humidity}%,风速:${current.windspeedKmph}km/h,].join(\n),},],};},);asyncfunctionmain(){consttransportnewStdioServerTransport();awaitserver.connect(transport);}main();对比 2.2 节的代码区别很明显不需要手动 importCallToolRequestSchema、ListToolsRequestSchema不需要自己写两个setRequestHandler注册Zod 自动帮你做参数校验和类型推导city参数直接有类型提示加新工具就是再加一行server.registerTool(...)想加第二个工具再加一段registerTool就行server.registerTool(get_air_quality,{description:查询空气质量,inputSchema:z.object({city:z.string().describe(城市名称),}),},async({city}){// TODO ...return{content:[{type:text,text:空气质量指数: 42 (优)}],};},);如果你觉得每次写{ content: [{ type: text, text: ... }] }有点啰嗦McpServer也提供了TextContent辅助函数可以直接返回字符串它会帮你包装成标准格式。总之McpServer是 SDK 提供的高层封装日常写 MCP Server 用它就够了。底层的Server Schema 写法适合你需要在更细粒度上控制行为的时候。相比手动注册每个 Handler数据驱动的方式更直观、更不容易出错。2.6 用代码测试 MCP Service前面 2.3 节在 cmd 终端用手敲 JSON-RPC 能验证 Server 是否工作但每次都要复制粘贴也挺累的。更实用的做法是写一个测试脚本用 MCP SDK 的Client来连接和调用。在同目录下新建test.mjsimport{Client}frommodelcontextprotocol/sdk/client/index.js;import{StdioClientTransport}frommodelcontextprotocol/sdk/client/stdio.js;asyncfunctionmain(){// 1. 启动 Server 进程并连接consttransportnewStdioClientTransport({command:node,args:[./index.js],// 类似在终端执行 node ./index.js 启动 mcp service});constclientnewClient({name:test-client,version:1.0.0},{capabilities:{}},);awaitclient.connect(transport);console.log(✅ 连接成功);// 2. 列出所有工具const{tools}awaitclient.listTools();console.log( 可用工具:,tools.map((t)t.name),);// 3. 调用工具constresultawaitclient.callTool({name:get_weather,arguments:{city:北京},});console.log( 查询结果:,result.content[0].text);// 4. 关闭连接awaitclient.close();}main().catch(console.error);然后运行nodetest.mjs输出类似✅ 连接成功 可用工具: [ get_weather ] 查询结果: 城市: 北京 温度: 28°C ...这个脚本覆盖了 MCP 客户端的完整流程连接 → 发现能力 → 调用工具 → 关闭。以后你改 Server 代码跑一遍test.mjs就能确认没坏。如果想测试异常情况比如传一个不存在的城市可以加一段try{awaitclient.callTool({name:get_weather,arguments:{city:}});}catch(err){console.log(❌ 预期内的错误:,err.message);}把test.mjs当作你的调试入口比手敲 JSON 省事多了。提示test.mjs 就是 Agent 连接 MCP Service 的核心代码。2.7 完整代码结构两种写法对应的文件结构mcp-weather-server/ ├── package.json ├── test.mjs └── index.js不管哪种写法核心逻辑都是一样的声明工具——告诉 AI “我能做什么”执行工具——收到调用请求干活返回结果你完全可以把 get_weather 替换成任何你想暴露的能力查数据库、搜文档、调用内部 API……模板是一样的。2.8 小结这一篇我们干了这些事用 MCP SDK 创建了一个Server 实例注册了Tools查询天气通过stdio 传输层启动 Server用手动输入 JSON-RPC在终端调试接入了真实 API最后用数据驱动方式简化了多工具注册现在这个 Server 已经能跑了但只能在终端里测试。下一篇我们会把它接入真正的 AI 客户端让 Claude 能直接通过它查询天气——那才是真正好玩的时刻。上一篇第 1 篇MCP 是什么——AI 世界的万能插座下一篇第 3 篇把 MCP 接入 AI以及生态里有什么