第 2 篇:手写一个 MCP Server——从零到跑通

第 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);

代码很简单,核心就四件事:

  1. 创建 Server 实例------告诉客户端我是谁、我能做什么。
  2. 注册 tools/list------客户端问我"你有什么工具?",我回答"有一个 get_weather 工具"。
  3. 注册 tools/call------客户端说"调用一下 get_weather,参数是 city=北京",我执行查询并返回结果。
  4. 连接传输层------这里用 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 CallToolRequestSchemaListToolsRequestSchema
  • 不需要自己写两个 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 能直接通过它查询天气------那才是真正好玩的时刻。


上一篇:第 1 篇:MCP 是什么?------AI 世界的"万能插座"

下一篇:第 3 篇:把 MCP 接入 AI,以及生态里有什么