第 2 篇:手写一个 MCP Server------从零到跑通
上一篇讲了 MCP 的概念,这一篇直接上手。我们用 Node.js 写一个能用的 MCP 服务器------它能查询天气。
目标很简单:写完之后,任何支持 MCP 的 AI 客户端都可以通过这个 Server 获取天气信息。
2.1 准备工作
你需要装好:
- Node.js
- 一个趁手的编辑器
然后建个目录,初始化项目:
bash
mkdir mcp-weather-server
cd mcp-weather-server
npm init -y
安装 MCP SDK:
bash
npm install @modelcontextprotocol/sdk
SDK 帮我们处理了底层的 JSON-RPC 通信、传输层、协议握手。我们只需要关心业务逻辑。
2.2 实现一个最简单的 Server
在项目根目录下新建 index.js:
javascript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
// 1. 创建一个 Server 实例
const server = new Server(
{
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 (name === "get_weather") {
const city = args?.city || "深圳";
// 模拟天气数据
const weatherData = {
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),
},
],
};
}
throw new Error(`未知工具: ${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 传输
const transport = new StdioServerTransport();
await server.connect(transport);
代码很简单,核心就四件事:
- 创建 Server 实例------告诉客户端我是谁、我能做什么。
- 注册
tools/list------客户端问我"你有什么工具?",我回答"有一个 get_weather 工具"。 - 注册
tools/call------客户端说"调用一下 get_weather,参数是 city=北京",我执行查询并返回结果。 - 连接传输层------这里用 stdio,就是通过标准输入输出通信。
在 package.json 里加一行 "type": "module" 或者把文件后缀改成 .mjs,然后跑一下:
bash
node ./index.js # 启动 mcp service
跑起来之后没有任何输出?正常。因为它现在通过 stdio 通信,不是输出到终端。
2.3 在终端手敲 JSON-RPC 调试
Server 跑起来没输出,怎么知道它工作正常?直接打开终端跟它"对话"。
MCP 底层用的是 JSON-RPC 协议,所有消息都是 JSON 格式。我们可以手动输入消息来走一遍完整流程。
先启动 Server(它会在后台等待输入):
bash
node ./index.js
看不到输出?对,因为它正在 stdin 上等着你发消息过去。
现在你在终端里输入(逐行复制,每行是一条消息):
第一步:初始化握手
json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"LJ-agent","version":"0.5.0"}}}
Server 会返回:
json
{"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"}
第二步:通知初始化完成
json
{"jsonrpc":"2.0","method":"notifications/initialized"}
(通知不需要返回。)
第三步:问 Server 有什么工具
json
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
返回:
json
{"result":{"tools":[{"name":"get_weather","description":"查询指定城市的实时天气","inputSchema":{"type":"object","properties":{"city":{"type":"string","description":"城市名称,如 北京"}},"required":["city"]}}]},"jsonrpc":"2.0","id":2}
第四步:调用工具查天气
json
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name": "get_weather","arguments": { "city": "北京"}}}
返回:
json
{"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。
javascript
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: "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 (name === "get_weather") {
const city = args?.city || "深圳";
// 调用 wttr.in API
const res = await fetch(`https://wttr.in/${city}?format=j1`);
const data = await res.json();
const current = data.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"),
},
],
};
}
throw new Error(`未知工具: ${name}`);
});
const transport = new StdioServerTransport();
await server.connect(transport);
注意:这段需要 Node.js v18+ 才支持 fetch。如果版本低,可以用 node-fetch 包。
现在你调用 get_weather 就能拿到真实的天气数据了。
2.5 简化写法:McpServer + Zod
2.2 的代码还有一个更清爽的写法------MCP SDK 提供了更高层的 McpServer 类,配合 Zod 做参数校验,代码可以写得更简洁。
先安装 Zod:
bash
npm install zod
然后用 McpServer 重写 index.js:
javascript
import {
McpServer,
StdioServerTransport,
} from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const server = new McpServer({
name: "weather-server",
version: "1.0.0",
});
server.registerTool(
"get_weather",
{
description: "查询指定城市的实时天气",
inputSchema: z.object({
city: z.string().describe("城市名称,如 北京"),
}),
},
async ({ city }) => {
const res = await fetch(`https://wttr.in/${city}?format=j1`);
const data = await res.json();
const current = data.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"),
},
],
};
},
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main();
对比 2.2 节的代码,区别很明显:
- 不需要手动 import
CallToolRequestSchema、ListToolsRequestSchema - 不需要自己写两个
setRequestHandler注册 - Zod 自动帮你做参数校验和类型推导,
city参数直接有类型提示 - 加新工具就是再加一行
server.registerTool(...)
想加第二个工具,再加一段 registerTool 就行:
javascript
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.mjs:
javascript
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
async function main() {
// 1. 启动 Server 进程并连接
const transport = new StdioClientTransport({
command: "node",
args: ["./index.js"], // 类似在终端执行: node ./index.js 启动 mcp service
});
const client = new Client(
{ name: "test-client", version: "1.0.0" },
{ capabilities: {} },
);
await client.connect(transport);
console.log("✅ 连接成功");
// 2. 列出所有工具
const { tools } = await client.listTools();
console.log(
"📦 可用工具:",
tools.map((t) => t.name),
);
// 3. 调用工具
const result = await client.callTool({
name: "get_weather",
arguments: { city: "北京" },
});
console.log("🌤 查询结果:", result.content[0].text);
// 4. 关闭连接
await client.close();
}
main().catch(console.error);
然后运行:
bash
node test.mjs
输出类似:
✅ 连接成功
📦 可用工具: [ 'get_weather' ]
🌤 查询结果: 城市: 北京
温度: 28°C
...
这个脚本覆盖了 MCP 客户端的完整流程:连接 → 发现能力 → 调用工具 → 关闭 。以后你改 Server 代码,跑一遍 test.mjs 就能确认没坏。
如果想测试异常情况,比如传一个不存在的城市,可以加一段:
javascript
try {
await client.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 能直接通过它查询天气------那才是真正好玩的时刻。