# 从 0 写一个简单 MCP

> 用 TypeScript 写一个本地 MCP Server，再交给 AI Client 使用。

![从 0 写一个简单 MCP：AI 通过 MCP 工具箱获得问候、加法和时间能力](https://herblab.online/qwen-imgs/simple-mcp-01-cover-v1.png)<!-- display-width:600 -->

这篇教程，我们从一个空文件夹开始，写一个简单的 MCP Server。

它会提供三个小功能：打招呼、计算加法、查询指定时区的当前时间。我们先写最小版本，用 MCP Inspector 手动调用；确认代码没问题后，再把它接入 CodeBuddy。

MCP 全称 Model Context Protocol。它规定 AI 应用怎样发现和调用外部能力。先记住两个词就够了：**Tool 是一个具体能力，MCP Server 是把这些能力开放给 AI 的程序。**

严格来说，本文写的是一个 MCP Server。标题里的“写一个 MCP”沿用大家平时更常见的说法。

## 1. 准备环境

这次使用 Node.js、npm 和 TypeScript。MCP TypeScript SDK v2 的官方入门文档要求 Node.js 20 或更高版本；本文原始实验使用 Node.js 22.23.1，之后又在 Node.js 24.15.0 上复验了一遍。

打开 PowerShell，检查版本：

```powershell
node -v
npm -v
```

![实测环境为 Node.js 22.23.1 与 npm 10.9.8，用户名、电脑名和本地路径已裁掉](https://herblab.online/qwen-imgs/simple-mcp-02-environment-v1.png)<!-- display-width:360 -->

只要两条命令都能返回版本号，就可以继续。版本不同没关系，Node.js 不低于 20 即可。

如果提示“无法识别 node”，请从 [Node.js 官网](https://nodejs.org/)安装 LTS 版本，安装后重新打开 PowerShell。已经能看到版本号时，不需要重装。

代码编辑器可以使用 VS Code、CodeBuddy 或其他能编辑 TypeScript 的工具。本文截图来自 CodeBuddy，但写 MCP 并不依赖它。

## 2. 创建项目

先创建文件夹，再初始化 npm 项目：

```powershell
mkdir my_mcp
cd my_mcp
npm init -y
```

![运行 npm init -y 后生成 package.json，命令与结果来自同一次真实操作](https://herblab.online/qwen-imgs/simple-mcp-09-npm-init-v1.png)<!-- display-width:560 -->

看到 `package.json` 就说明 npm 项目初始化成功了。

接着开启 ES Module，并添加启动命令：

```powershell
npm pkg set type=module
npm pkg set scripts.start="tsx src/index.ts"
```

安装这次要用的三个依赖：

```powershell
npm install @modelcontextprotocol/server@2.0.0 zod@4.4.3 tsx@4.23.12
```

![安装 MCP Server SDK、Zod 和 tsx 后，npm 报告安装完成且未发现漏洞](https://herblab.online/qwen-imgs/simple-mcp-10-dependencies-v2.png)<!-- display-width:560 -->

它们各自负责一件事：

- `@modelcontextprotocol/server`：创建 MCP Server；
- `zod`：描述并检查 Tool 收到的参数；
- `tsx`：直接运行 TypeScript 文件，省去单独编译。

最后创建源码目录和入口文件：

```powershell
mkdir src
New-Item -ItemType File -Path .\src\index.ts
```

![创建 src 目录和 index.ts 后，用 ls 确认项目文件已经就位](https://herblab.online/qwen-imgs/simple-mcp-11-entry-created-v1.png)<!-- display-width:560 -->

现在的目录应该是：

```text
my_mcp/
├─ node_modules/
├─ src/
│  └─ index.ts
├─ package-lock.json
└─ package.json
```

**到这里的成功标志：** `package.json` 中能看到 `"type": "module"`，依赖安装没有以红色报错结束。

如果安装失败，先运行 `node -v` 和 `npm -v`。环境本身没有版本号时，继续改代码也解决不了问题。

## 3. 写第一个 hello Tool

打开 `src/index.ts`，粘贴下面的代码：

```ts
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";

function createServer(): McpServer {
  const server = new McpServer({
    name: "my-first-mcp",
    version: "1.0.0",
  });

  server.registerTool(
    "hello",
    {
      description: "向一个人打招呼",
      inputSchema: z.object({
        name: z.string().describe("要打招呼的人的名字"),
      }),
    },
    async ({ name }) => {
      return {
        content: [
          {
            type: "text",
            text: `你好，${name}！`,
          },
        ],
      };
    },
  );

  return server;
}

void serveStdio(createServer);
console.error("My MCP server running on stdio");
```

先看真正会影响调用的几处：

- `new McpServer(...)` 创建 Server，`my-first-mcp` 是它向客户端报告的名字；
- `registerTool('hello', ...)` 注册一个名为 `hello` 的 Tool；
- `description` 告诉 AI 这个 Tool 是干什么的；
- `inputSchema` 规定参数里必须有一个字符串 `name`；
- 最后的异步函数是 handler，也就是收到调用后真正执行的代码；
- `content` 是 Tool 交回客户端的结果。

`z.object(...)` 写出的规则叫 Schema。它像入口处的检查表：这里要求 `name` 必须是字符串。客户端少传参数或传错类型时，SDK 会在进入 handler 前拒绝这次调用。

最下面的 `serveStdio(createServer)` 让 Server 通过标准输入和标准输出与客户端通信。这种方式叫 stdio，适合由本地 AI 客户端启动的 MCP Server。

还有一个容易踩的坑：**stdio 模式不要用 `console.log()` 打日志。** stdout 要留给协议消息，普通日志请写到 stderr，所以这里使用 `console.error()`。

## 4. 先运行一次

保存文件，在项目根目录运行：

```powershell
npm start
```

终端应该显示：

```text
My MCP server running on stdio
```

![用 tsx 启动 Server 后，终端输出 My MCP server running on stdio 并继续等待连接](https://herblab.online/qwen-imgs/simple-mcp-12-server-running-v1.png)<!-- display-width:560 -->

然后它会停在那里，没有继续输出。这是正常现象。MCP Server 已经启动，正在等待 Client 从 stdin 发来请求。

**成功标志：** 终端没有报错，进程持续运行。

按 `Ctrl+C` 停止它。下一步由 Inspector 帮我们启动 Server，因此这里不要继续占着终端。

如果遇到 `Cannot find package`，先确认当前路径下确实有 `package.json` 和 `node_modules`，再重新运行 `npm install`。

## 5. 用 MCP Inspector 调用 hello

MCP Inspector 是官方提供的本地调试工具。它会启动我们的命令，连接 Server，再把 Tool 列表和调用结果展示在网页里。

在 `my_mcp` 根目录运行：

```powershell
npx @modelcontextprotocol/inspector npx tsx src/index.ts
```

第一次运行需要下载 Inspector，稍等片刻后浏览器会打开本地页面。点击 `Connect`，再进入 `Tools`。

![MCP Inspector 已通过 stdio 启动 npx tsx src/index.ts，Server 状态为 Connected](https://herblab.online/qwen-imgs/simple-mcp-13-inspector-connected-v1.png)<!-- display-width:600 -->

此时左侧应该出现 `hello`：

![Inspector 连接 my-first-mcp 后列出 hello，并根据 inputSchema 生成 name 输入框](https://herblab.online/qwen-imgs/simple-mcp-03-inspector-hello-v1.png)<!-- display-width:600 -->

选择 `hello`，在 `name` 中输入“盾山”。输入完成后应该是这样：

![Inspector 根据 hello 的输入结构生成 name 字段，测试值填写为“盾山”](https://herblab.online/qwen-imgs/simple-mcp-14-hello-input-v1.png)<!-- display-width:600 -->

点击 `Execute Tool`，结果区域会显示：

![调用 hello 后，Inspector 收到“你好，盾山！”](https://herblab.online/qwen-imgs/simple-mcp-04-hello-result-v1.png)<!-- display-width:560 -->

到这里，我们已经完成了第一次真实调用。Inspector 先询问 Server 有哪些 Tool，再根据 `inputSchema` 生成输入框；点击按钮后，它把参数交给 `hello` 的 handler，最后显示 handler 返回的 `content`。

如果 Tools 中没有 `hello`，先回到启动 Inspector 的终端看报错。最常见的原因是命令运行位置不在 `my_mcp` 根目录，或者 `src/index.ts` 没有保存。

Inspector 启动时会在终端和本地 URL 中放入临时 Auth Token。它用于保护本地调试服务，不要把完整 URL 或 Token 发到公开文章和群聊里。

## 6. 再加两个 Tool

`hello` 已经证明基础链路能工作。接下来在同一个 Server 里加入：

- `add`：接收两个数字，返回它们的和；
- `get_current_time`：接收一个时区，返回当地时间。

把 `src/index.ts` 替换为下面的完整版本：

```ts
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";

function createServer(): McpServer {
  const server = new McpServer({
    name: "my-first-mcp",
    version: "1.0.0",
  });

  server.registerTool(
    "hello",
    {
      description: "向一个人打招呼",
      inputSchema: z.object({
        name: z.string().describe("要打招呼的人的名字"),
      }),
    },
    async ({ name }) => {
      return {
        content: [
          {
            type: "text",
            text: `你好，${name}！`,
          },
        ],
      };
    },
  );

  server.registerTool(
    "get_current_time",
    {
      description: "获取指定时区的当前时间",
      inputSchema: z.object({
        timezone: z
          .string()
          .optional()
          .describe("时区，例如 Asia/Shanghai；不填写则使用电脑系统时区"),
      }),
    },
    async ({ timezone }) => {
      try {
        const formatter = new Intl.DateTimeFormat("zh-CN", {
          timeZone: timezone,
          dateStyle: "full",
          timeStyle: "long",
        });

        const currentTime = formatter.format(new Date());

        return {
          content: [
            {
              type: "text",
              text: currentTime,
            },
          ],
        };
      } catch {
        return {
          content: [
            {
              type: "text",
              text: `无法识别这个时区：${timezone}`,
            },
          ],
          isError: true,
        };
      }
    },
  );

  server.registerTool(
    "add",
    {
      description: "计算两个数字的和",
      inputSchema: z.object({
        a: z.number().describe("第一个数字"),
        b: z.number().describe("第二个数字"),
      }),
    },
    async ({ a, b }) => {
      const result = a + b;

      return {
        content: [
          {
            type: "text",
            text: `${a} + ${b} = ${result}`,
          },
        ],
      };
    },
  );

  return server;
}

void serveStdio(createServer);
console.error("My MCP server running on stdio");
```

这里没有引入新的 MCP 写法。两个 Tool 仍然由名称、说明、参数 Schema、handler 和返回结果组成。

时间 Tool 多做了一层错误处理。`Asia/Shanghai` 是合法时区；如果传入不存在的名称，handler 会返回 `isError: true`，让客户端知道这次调用失败了。

## 7. 再用 Inspector 检查

修改代码后，停止旧的 Inspector，再重新运行：

```powershell
npx @modelcontextprotocol/inspector npx tsx src/index.ts
```

进入 Tools，先调用 `add`：

```text
a = 5
b = 6
```

预期结果是：

```text
5 + 6 = 11
```

![在 Inspector 中给 add 传入 5 和 6，Server 返回 5 + 6 = 11](https://herblab.online/qwen-imgs/simple-mcp-15-inspector-add-v1.png)<!-- display-width:600 -->

再调用 `get_current_time`：

```text
timezone = Asia/Shanghai
```

你会看到当时的上海日期和时间。不同 Node.js 版本可能把时区后缀显示为“中国标准时间”或 `GMT+8`，两者都正常。

![在 Inspector 中给 get_current_time 传入 Asia/Shanghai，并得到对应时间](https://herblab.online/qwen-imgs/simple-mcp-16-inspector-time-v1.png)<!-- display-width:600 -->

打开 Inspector 右侧的 `Protocol`，还能看到一次 Tool 调用的原始消息：

![Protocol 面板中的 tools/call：客户端传入 add 参数，Server 返回 5 + 6 = 11](https://herblab.online/qwen-imgs/simple-mcp-05-protocol-call-v1.png)<!-- display-width:420 -->

这时再认识两个协议动作会轻松很多：

- `tools/list`：Client 询问 Server 提供哪些 Tool；
- `tools/call`：Client 指定 Tool 名称和参数，要求 Server 执行。

截图中的 `Parameters` 是发给 `add` 的参数，`Response` 是 Server 返回的结果。界面显示 `LEGACY` 时也不用改代码：v2 的 `serveStdio` 默认兼容旧版 Client，Inspector 会根据连接协商协议版本。

下面这张图把目前的关系收在一起：

![AI Client 先通过 tools/list 发现能力，再用 tools/call 让 MCP Server 执行 Tool](https://herblab.online/qwen-imgs/simple-mcp-06-flow-v1.png)<!-- display-width:600 -->

Tool 是 `hello`、`add` 和 `get_current_time` 这些具体能力。MCP Server 负责登记并执行它们，AI Client 负责发现和调用。

## 8. 接入 CodeBuddy

Inspector 适合开发阶段手动测试。代码确认没问题后，可以把同一个 Server 交给真正的 AI Client。

本文用 CodeBuddy 演示。其他支持本地 stdio MCP 的客户端也能使用同样的 Server，只是配置入口和 JSON 文件位置可能不同。

先停止 Inspector。然后在 CodeBuddy 中打开设置，进入 `MCP`，点击“配置 MCP”：

![CodeBuddy 设置中的 MCP 入口，可手动配置本地 MCP Server](https://herblab.online/qwen-imgs/simple-mcp-07-codebuddy-settings-v1.png)<!-- display-width:560 -->

在 PowerShell 中取得入口文件的绝对路径：

```powershell
(Resolve-Path .\src\index.ts).Path
```

把得到的路径填入 MCP 配置。下面使用一个示例路径，**请换成你自己的实际结果**：

```json
{
  "mcpServers": {
    "my-first-mcp": {
      "command": "npx",
      "args": ["tsx", "C:\\你的路径\\my_mcp\\src\\index.ts"]
    }
  }
}
```

Windows 的 JSON 路径要写双反斜杠，例如 `C:\\Users\\...`。当然，这一步完全可以让 CodeBuddy 帮你做，和它说就行。

![也可以直接把配置任务交给 CodeBuddy：让它把刚写好的 index.ts 添加到 MCP Server](https://herblab.online/qwen-imgs/simple-mcp-17-codebuddy-config-prompt-v1.png)<!-- display-width:560 -->

保存后重启或重新连接 MCP。CodeBuddy 的 MCP 列表里出现 `my-first-mcp`，并且状态正常，就说明它已经启动了我们的 TypeScript 程序。

如果连接失败，先在同一个项目目录运行 `npx tsx src/index.ts`。这条命令能启动时，再检查配置中的绝对路径；它本身也报错时，先修代码或依赖。

## 9. 让 AI 自己选择 Tool

现在给 CodeBuddy 一条普通的自然语言要求：

```text
用我的 MCP 帮我算一下 56 + 78，
再用我的 MCP 查看现在上海几点
```

这次不需要手动点 `add` 或 `get_current_time`。CodeBuddy 读取 Tool 的名称、`description` 和 `inputSchema`，判断该用哪个 Tool，再填入参数发起调用。

实际结果如下：

![CodeBuddy 自动调用 add 与 get_current_time，得到 134 和上海时间](https://herblab.online/qwen-imgs/simple-mcp-08-codebuddy-final-v1.png)<!-- display-width:520 -->

CodeBuddy 先调用 `add`，传入 56 和 78，得到 134；随后调用 `get_current_time`，传入 `Asia/Shanghai`，再把两次返回内容整理成回答。

这也说明 `description` 不是写给人看的装饰。AI Client 正是根据它理解 Tool 的用途。名称和说明写得含糊，客户端就更容易选错。

## 10. 常见问题

### npm start 后为什么一直不退出？

stdio Server 正在等待 Client 请求。看到启动日志且没有报错，就可以按 `Ctrl+C` 停止，再让 Inspector 或 CodeBuddy 启动它。

### Inspector 为什么看不到新 Tool？

先保存 `src/index.ts`，停止旧进程，再重新执行 Inspector 命令。Inspector 启动的是一个新进程，不会自动读取旧进程之外的修改。

### 为什么不能用 console.log？

stdout 是 stdio 的协议通道。`console.log()` 会把普通文字混进 JSON-RPC 消息，客户端可能因此解析失败。日志使用 `console.error()`。

### CodeBuddy 能连接，AI 却不调用 Tool？

先在 Inspector 里确认 Tool 能列出、参数能提交。然后检查 `description` 是否准确说明用途。连接成功只能证明 Server 已启动，AI 是否选择 Tool 还取决于任务、描述和当前客户端行为。

## 11. 做完以后，我们学会了什么？

这次完成了三件事：

1. 创建一个通过 stdio 工作的 MCP Server；
2. 注册并用 Inspector 调试三个 Tool；
3. 把 Server 接入真实 AI Client，让 AI 根据要求选择 Tool。

现在回头看，MCP 的作用很具体：它给 AI Client 一套统一方式，用来发现外部能力、传入参数并取得结果。

今天的 handler 只做了问候、加法和时间查询。把其中的代码换成读取文件、调用 API 或查询数据库，MCP Server 的基本结构仍然一样。

下一步，可以试着写一个真正会读取本地文件的 Tool。

## 参考资料

- [MCP TypeScript SDK v2：Build your first server](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/get-started/first-server.md)
- [MCP TypeScript SDK v2：Serve over stdio](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/serving/stdio.md)
- [MCP TypeScript SDK v2：Packages and subpath exports](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/get-started/packages.md)
- [MCP Inspector](https://github.com/modelcontextprotocol/inspector)
