今天的唯一目标

从空文件夹创建一个 TypeScript MCP Server,依次写出 hello、add 和时间 Tool,用 MCP Inspector 调试后接入 CodeBuddy。

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

这篇教程,我们从一个空文件夹开始,写一个简单的 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,用户名、电脑名和本地路径已裁掉

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

如果提示“无法识别 node”,请从 Node.js 官网安装 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,命令与结果来自同一次真实操作

看到 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 报告安装完成且未发现漏洞

它们各自负责一件事:

  • @modelcontextprotocol/server:创建 MCP Server;
  • zod:描述并检查 Tool 收到的参数;
  • tsx:直接运行 TypeScript 文件,省去单独编译。

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

powershell可复制后修改
mkdir src
New-Item -ItemType File -Path .\src\index.ts
创建 src 目录和 index.ts 后,用 ls 确认项目文件已经就位

现在的目录应该是:

text可复制后修改
my_mcp/
├─ node_modules/
├─ src/
│  └─ index.ts
├─ package-lock.json
└─ package.json

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

如果安装失败,先运行 node -vnpm -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 并继续等待连接

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

成功标志: 终端没有报错,进程持续运行。

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

如果遇到 Cannot find package,先确认当前路径下确实有 package.jsonnode_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

此时左侧应该出现 hello

Inspector 连接 my-first-mcp 后列出 hello,并根据 inputSchema 生成 name 输入框

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

Inspector 根据 hello 的输入结构生成 name 字段,测试值填写为“盾山”

点击 Execute Tool,结果区域会显示:

调用 hello 后,Inspector 收到“你好,盾山!”

到这里,我们已经完成了第一次真实调用。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

再调用 get_current_time

text可复制后修改
timezone = Asia/Shanghai

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

在 Inspector 中给 get_current_time 传入 Asia/Shanghai,并得到对应时间

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

Protocol 面板中的 tools/call:客户端传入 add 参数,Server 返回 5 + 6 = 11

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

  • 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

Tool 是 helloaddget_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

在 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

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

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

9. 让 AI 自己选择 Tool

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

text可复制后修改
用我的 MCP 帮我算一下 56 + 78,
再用我的 MCP 查看现在上海几点

这次不需要手动点 addget_current_time。CodeBuddy 读取 Tool 的名称、descriptioninputSchema,判断该用哪个 Tool,再填入参数发起调用。

实际结果如下:

CodeBuddy 自动调用 add 与 get_current_time,得到 134 和上海时间

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。

参考资料