<!-- title: TypeScript MCP服务器从零工程化与三作用域接入 -->

## 概述

本教程面向已熟悉命令行和 TypeScript 基础的开发者，目标是带你**从零构建一个生产可用的本地 MCP 服务器，并完整接入 Claude Code 的三套作用域（local / project / user）**。内容覆盖官方 TypeScript 高层 `McpServer` API、Zod 输入校验、stdio 传输工程化细节、结构化输出、MCP Inspector 调试，并前瞻性地讲解 2026 年规范（SEP-2577 弃用 Roots/Sampling/Logging、2026-07-28 无状态候选版）带来的迁移影响。学完后你将能独立交付一个团队可复用、随仓库提交、面向未来不踩坑的 MCP 工具。

---

## 前置条件

- **Node.js ≥ 20**（推荐 22 LTS），`node -v` 可用
- 基础 TypeScript 与 npm 经验（能读懂 `tsconfig.json`、ESM 模块）
- 已安装并可用 **Claude Code** CLI（`claude --version`）
- 一台可联网的 macOS / Linux / Windows（本文命令以 Linux/macOS 为准，Windows 下用 PowerShell 等价命令）
- 可选：安装 Git，用于实战案例

不需要你此前写过 MCP 服务器——这正是本教程的起点。

---

## 第一步：理解 MCP 的最小心智模型

MCP（Model Context Protocol）是 Anthropic 主导的开放协议，让 AI 客户端（如 Claude Code）以标准方式连接外部工具与数据。一个 MCP 服务器对外暴露三类"原语（primitive）"：

| 原语 | 角色 | 类比 |
|------|------|------|
| **Tool** | 模型可主动调用的函数 | REST 里的 POST 接口 |
| **Resource** | 模型可读取的数据源（含 URI 模板） | REST 里的 GET 接口 |
| **Prompt** | 预制的、可参数化的提示词模板 | 函数库里的"快捷指令" |

客户端与服务器之间通过"传输层（transport）"通信。本地工具用 **stdio**（标准输入输出），远程服务用 **Streamable HTTP**。本教程聚焦 stdio——这也是 Claude Code 接入本地工具的标准方式。

```mermaid
flowchart LR
    CC[Claude Code 客户端] -- JSON-RPC over stdio --> TRP[StdioServerTransport]
    TRP <--> SRV[McpServer]
    SRV --> T[Tools]
    SRV --> R[Resources]
    SRV --> P[Prompts]
    T --> FS[(本地文件 / Git / 命令)]
    R --> FS
```

记住一条铁律：**stdio 模式下，stdout 是协议通道，绝不能往 stdout 打印日志**——否则协议帧被污染，握手直接失败。所有日志走 stderr。

---

## 第二步：脚手架与依赖

新建项目目录并初始化：

```bash
mkdir codestats-mcp && cd codestats-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx tsup
npx tsc --init
```

调整 `package.json` 关键字段（这是让它能被 `node` 与 `npx` 正确驱动的核心）：

```json
{
  "name": "codestats-mcp",
  "version": "0.1.0",
  "type": "module",
  "bin": { "codestats-mcp": "./dist/index.js" },
  "main": "./dist/index.js",
  "scripts": {
    "build": "tsup src/index.ts --format esm --dts",
    "dev": "tsx src/index.ts",
    "start": "node dist/index.js"
  }
}
```

`tsconfig.json` 建议开启严格模式与 NodeNext 模块解析：

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist",
    "skipLibCheck": true
  },
  "include": ["src"]
}
```

**预期结果**：`npm run dev` 能启动且不报错（此时还没有逻辑，进程会等待 stdin）。**常见问题**：若报 `ERR_UNKNOWN_FILE_EXTENSION`，几乎都是忘了 `"type": "module"` 或 `moduleResolution` 没设成 `NodeNext`。

---

## 第三步：用高层 API 写第一个 Tool

创建 `src/index.ts`。高层 `McpServer` 把协议细节封装掉了，你只需声明"工具名 + Zod schema + 处理函数"：

```typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { readdir, stat } from "node:fs/promises";
import { join, extname } from "node:path";

const server = new McpServer({
  name: "codestats-mcp",
  version: "0.1.0",
});

// 工具：统计某目录下指定扩展名的文件数量
server.tool(
  "count_files",                                  // 工具名
  "Count files of a given extension in a directory", // 描述（模型据此决定是否调用）
  {
    directory: z.string().describe("Absolute or relative directory path"),
    extension: z.string().default(".ts").describe("File extension, e.g. .ts"),
  },
  async ({ directory, extension }) => {
    const entries = await readdir(directory, { withFileTypes: true });
    const matched = entries
      .filter((e) => e.isFile() && extname(e.name) === extension)
      .map((e) => e.name);
    return {
      content: [
        {
          type: "text",
          text: `Found ${matched.length} ${extension} files:\n${matched.join("\n")}`,
        },
      ],
    };
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);
```

要点拆解：

1. **描述即"提示词"**：`server.tool` 的第二个参数是给模型看的自然语言说明，写得越具体、越强调副作用与返回结构，模型调用准确率越高。
2. **Zod 即"接口契约"**：`z.string()`、`.default()`、`.describe()` 会自动转成 JSON Schema 下发给客户端，模型据此生成参数；校验失败由 SDK 拦截，不会进入你的处理函数。
3. **返回结构固定**：必须是 `{ content: ContentItem[], isError?: boolean }`。`ContentItem` 通常是 `{ type: "text", text }`。

**预期结果**：`npm run build && node dist/index.js` 启动后静默等待——因为 stdio 服务器靠 stdin 喂 JSON-RPC 消息，直接 `node` 跑看不到交互，这就是下一步要引入 Inspector 的原因。

---

## 第四步：补齐 Resource 与 Prompt

为了让服务器"三原语齐全"，再加一个资源和一个提示词。资源适合"模型按需读取"的数据，提示词适合"固化最佳问法"的场景：

```typescript
import { ReadResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";

// 资源：读取单个文件内容，使用 URI 模板
server.resource(
  "file",
  new ReadResourceTemplate("file://{path}", ({ path }) => ({ path })),
  async (uri, { path }) => {
    const { readFile } = await import("node:fs/promises");
    const text = await readFile(path, "utf8");
    return {
      contents: [
        { uri: uri.href, mimeType: "text/plain", text },
      ],
    };
  }
);

// 提示词：生成"代码库摘要"的预设问法
server.prompt(
  "summarize_codebase",
  { directory: z.string() },
  async ({ directory }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `请分析目录 ${directory} 的代码结构，先列出主要模块，再给出 3 条重构建议。`,
        },
      },
    ],
  })
);
```

**何时用 Tool 而非 Resource？** 经验法则：有副作用（写文件、发请求、改状态）→ Tool；纯读取、幂等、可缓存 → Resource；需要"封装一套提问套路"给非技术用户 → Prompt。三者并非互斥，但混用时要克制——原语越多，模型的选择空间越大，误触发概率也越高。

---

## 第五步：结构化输出与分页

工具返回的不仅是文本。SDK 支持多种 `content` 类型，善用它们能显著降低模型的理解成本：

```typescript
// 1. 图片输出（例如把统计结果画成图后回传）
import { readFileSync } from "node:fs";
server.tool("logo_b64", "Return a PNG as base64 image", {}, async () => ({
  content: [
    {
      type: "image",
      data: readFileSync("./logo.png").toString("base64"),
      mimeType: "image/png",
    },
  ],
}));

// 2. 内嵌资源（把文件作为"可引用资源"返回，而非纯文本）
server.tool(
  "list_files_paged",
  "List files with cursor-based pagination",
  {
    directory: z.string(),
    cursor: z.string().optional().describe("Opaque cursor from previous page"),
    pageSize: z.number().int().min(1).max(100).default(20),
  },
  async ({ directory, cursor, pageSize }) => {
    const all = (await readdir(directory)).sort();
    const startIdx = cursor ? all.indexOf(cursor) + 1 : 0;
    const page = all.slice(startIdx, startIdx + pageSize);
    const nextCursor =
      startIdx + pageSize < all.length ? page[page.length - 1] : undefined;
    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(
            { files: page, nextCursor },
            null,
            2
          ),
        },
      ],
    };
  }
);
```

**设计要点**：

- **分页用不透明游标（opaque cursor）**，而非页码。游标可随时换底层实现，模型只需把 `nextCursor` 原样回传即可。
- 大列表**务必分页**。一次性返回上万条会让上下文爆炸——这正是后文"性能"章节会强调的成本红线。
- 图片输出适合"图表/截图/设计稿"，但务必说明返回的是图，让模型知道该用视觉能力解析。

---

## 第六步：stdio 传输的工程化

前面强调过 stdout 不能写日志。把日志、错误处理规范化：

```typescript
import { setTimeout as sleep } from "node:timers/promises";

const log = (...args: unknown[]) =>
  process.stderr.write(`[codestats] ${args.join(" ")}\n`);

// 统一的错误返回：用 isError 标记失败，而非抛异常
async function safe(fn: () => Promise<string>) {
  try {
    return { content: [{ type: "text", text: await fn() }] };
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    log("ERROR:", msg);
    return {
      isError: true,
      content: [
        {
          type: "text",
          text: `Tool failed: ${msg}. Check the directory path and permissions.`,
        },
      ],
    };
  }
}

// 改造后的 count_files，复用 safe
server.tool(
  "count_files_safe",
  "Count files of a given extension (robust version)",
  {
    directory: z.string(),
    extension: z.string().default(".ts"),
  },
  async ({ directory, extension }) =>
    safe(async () => {
      const entries = await readdir(directory, { withFileTypes: true });
      const n = entries.filter(
        (e) => e.isFile() && extname(e.name) === extension
      ).length;
      return `Found ${n} ${extension} files in ${directory}`;
    })
);

process.on("uncaughtException", (e) => log("uncaught:", e.message));
process.on("unhandledRejection", (e) => log("unhandled:", String(e)));
```

**为什么用 `isError: true` 而不是抛异常？** 抛异常会让 JSON-RPC 层返回一个内部错误，模型看到的是"工具崩溃"，往往无法自我修复；而 `isError: true` 把失败信息当作正常返回内容，模型读到后能据此重试或换路径。这是把"鲁棒性"做进协议层的关键习惯。

---

## 第七步：构建打包与可执行入口

`package.json` 里已配置了 `bin` 字段和 `tsup`。构建后给入口加上 shebang，让产物能直接当命令跑：

```bash
npm run build
```

确认 `dist/index.js` 顶部有 `#!/usr/bin/env node`（`tsup` 的 `--banner` 可注入；或在 `src/index.ts` 顶部手写一行 `#!/usr/bin/env node`，构建时会保留）。验证：

```bash
./dist/index.js   # 应静默等待，按 Ctrl+C 退出
```

这一步的意义在于：Claude Code 的 MCP 配置里，`command` 要么指向解释器+脚本，要么指向这个可直接执行的二进制。能直接执行意味着团队同学 `npm i -g` 后无需关心路径。

---

## 第八步：Claude Code 三作用域接入

Claude Code 把 MCP 配置存在**三个作用域**，理解它们的差异是团队协作的基础：

| 作用域 | 存储位置 | 适用场景 | 是否随仓库 |
|--------|----------|----------|-----------|
| **local** | `~/.claude.json`（当前项目路径下） | 只在这台机器、这个项目用 | 否 |
| **project** | 项目根 `.mcp.json` | 团队共享，提交到 Git | ✅ 是 |
| **user** | `~/.claude.json`（全局） | 个人跨项目通用工具 | 否 |

```mermaid
flowchart TD
    A[claude mcp add] --> L{--scope}
    L -->|local 默认| LC[写入 ~/.claude.json 项目段]
    L -->|project| PC[写入 ./.mcp.json 可提交]
    L -->|user| UC[写入 ~/.claude.json 全局段]
    PC -->|git commit & push| TEAM[团队成员拉取即生效]
    UC --> ALL[你所有项目可用]
```

**推荐做法：本项目专用工具用 project 作用域**，让 `.mcp.json` 随仓库走：

```bash
claude mcp add codestats --scope project -- node ./dist/index.js
```

这会生成项目根的 `.mcp.json`：

```json
{
  "mcpServers": {
    "codestats": {
      "command": "node",
      "args": ["./dist/index.js"]
    }
  }
}
```

需要传环境变量或参数时：

```bash
claude mcp add codestats \
  --scope project \
  -e CODESTATS_ROOT=/srv/repos \
  -- node ./dist/index.js --verbose
```

对应的 `.mcp.json` 会带上 `env` 字段。**注意**：`.mcp.json` 里若含密钥（如 API Key），务必加入 `.gitignore`，改用 CI 注入或 user 作用域存放。

**验证接入**——这是最容易被忽略却最关键的一步：

```bash
claude mcp list                 # 列出所有已注册服务器
claude mcp get codestats        # 查看某服务器的详细配置与连接状态
```

`claude mcp get` 会显示服务器的 `✓ Connected` 状态及其暴露的 tools/resources/prompts 数量。若显示连接失败，回到第六步查 stderr 日志。进入 Claude Code 后输入 `/mcp` 也能实时查看每个服务器的健康度。

**常见问题与解决**：

- **`command not found: node`**：Claude Code 启动子进程时不一定加载你的 shell 配置。解决方法是写绝对路径 `/usr/local/bin/node`，或用 `npx`、或把二进制软链到 PATH 标准位置。
- **服务器秒连秒断**：99% 是 stdout 被污染（如某依赖往 stdout 打了 banner）。用 `2>/dev/null` 隔离法逐步定位，或在该依赖里关闭其 stdout 输出。
- **路径相对错位**：`.mcp.json` 里的相对路径是"相对于项目根"还是"相对于启动目录"在不同客户端版本有差异。生产环境一律用绝对路径或 `${workspaceFolder}` 类变量最稳。

---

## 第九步：用 MCP Inspector 调试

Claude Code 不利于"逐工具调试"——每次都要进会话、让模型去调。开发期强烈建议用官方 **MCP Inspector**，它是一个可视化测试台：

```bash
npx @modelcontextprotocol/inspector node ./dist/index.js
```

浏览器打开后，你能：

1. 在左侧手动触发某个 tool，填表传参，**立刻看到返回的原始 JSON**；
2. 查看 resources 列表、读取资源内容；
3. 渲染 prompts 的最终消息；
4. 观察 JSON-RPC 的完整请求/响应帧——这是排查 schema 不匹配、返回结构错误的金标准。

典型排错路径："模型没调用我的工具" → 先在 Inspector 里手动调通 → 确认返回结构合法 → 再回到 Claude Code 检查**工具描述是否足够让模型识别触发场景**。绝大多数"工具不触发"问题，根因是描述写得太抽象（例如只写 `count files` 而不写"何时该用、返回什么"）。

---

## 第十步：前瞻——适配 2026 规范

MCP 在 2026 年经历了一次大手术，开发新服务器时必须知晓，否则会建在即将弃用的地基上：

1. **SEP-2577（2026-04 提案、05 合并）**：正式弃用 **Roots、Sampling、Logging** 三个特性，给出约一年迁移窗口。它们当前仍可工作，但新项目不应再依赖：
   - **Logging**（服务端推日志给客户端）→ 改为直接走 stderr 或结构化返回。
   - **Sampling**（服务端反向请求客户端的模型生成）→ 迁移到 Elicitation 或在工具内自行完成。
   - **Roots**（客户端告知服务端可访问的目录边界）→ 改为显式参数传递。
2. **2026-07-28 候选规范**：MCP 走向**无状态化（stateless）**，削弱长会话语义，工具应设计成无状态、可重入。

对本教程的实践含义很明确：

- ✅ 我们的 `codestats` 全程**无状态**、靠参数传目录、日志走 stderr、不用 Sampling——天然符合新规范。
- ⚠️ 如果你想做"服务端主动推日志"或"服务端让 Claude 帮忙生成文案"，请意识到这些依赖被弃用，优先用 Elicitation（向用户收集输入）或把生成逻辑留在客户端。
- 🔒 面向长期维护的服务器，把 `@modelcontextprotocol/sdk` 锁定到具体 minor 版本，并在 CI 里跑一次 Inspector 冒烟测试，以防 SDK 升级引入 breaking change。

---

## 实战案例：codestats 代码统计服务器

把前述片段整合成一个完整可运行项目。目录结构：

```
codestats-mcp/
├── package.json
├── tsconfig.json
├── .mcp.json            # project 作用域配置（提交 Git）
└── src/
    └── index.ts         # 入口：tools + resource + prompt
```

`src/index.ts` 完整版（合并第一到第六步的代码，去掉重复 import）。能力清单：

- `count_files_safe` — 按扩展名统计文件，含错误兜底
- `list_files_paged` — 游标分页列文件
- `file://{path}` — 读取任意文件资源
- `summarize_codebase` — 代码库摘要提示词

跑通流程：

```bash
npm install && npm run build
claude mcp add codestats --scope project -- node ./dist/index.js
claude mcp get codestats   # 看到 ✓ Connected, tools: 2, resources: 1, prompts: 1
```

在 Claude Code 会话里测试真实调用：

```
> 统计一下 src 目录下有多少个 .ts 文件
（Claude 自动调用 count_files_safe，返回数量与文件名）

> 帮我看看 src/index.ts 的内容，然后用 summarize_codebase 的思路给重构建议
（Claude 先读取 file://src/index.ts 资源，再应用提示词框架）
```

把 `.mcp.json` 提交到仓库后，团队成员 `git pull` 即获得相同的工具能力——这是 MCP"团队级工具复用"的核心价值。

---

## 工作流程总览

```mermaid
flowchart TD
    A[npm init + 安装 SDK] --> B[写 McpServer tool/resource/prompt]
    B --> C[tsup 构建 + shebang]
    C --> D{MCP Inspector 手动验证}
    D -->|失败| B
    D -->|通过| E[claude mcp add --scope]
    E --> F[claude mcp get 验证连接]
    F --> G{Connected?}
    G -->|否| H[查 stderr 日志/路径/stdout 污染]
    H --> D
    G -->|是| I[会话内 /mcp + 真实调用测试]
    I --> J[提交 .mcp.json 团队共享]
    J --> K[CI 跑 Inspector 冒烟测试]
```

---

## 延伸阅读

- 官方 TypeScript SDK：https://github.com/modelcontextprotocol/typescript-sdk
- MCP 调试与 Inspector：https://modelcontextprotocol.io/docs/tools/debugging
- Claude Code 接入 MCP 官方文档：https://code.claude.com/docs/en/mcp
- SEP-2577 弃用说明：https://modelcontextprotocol.io/seps/2577-deprecate-roots-sampling-and-logging
- MCP 作用域配置深度解析：https://aiagent.nicedx.com/mcp-config-claude-code-scope/

---

## 训练营适用性评估

- **适合类型**：企业内训"AI 工程化进阶"、开发者社区的 MCP 专题工作坊、高校"大模型工具链"选修课。
- **教学时长**：约 **3.5–4.5 小时**（讲授 1.5h + 跟练 2h + 实战案例与排错 1h）。零 MCP 经验的学员建议先补 30 分钟协议概念。
- **前置衔接**：可与"Skill 开发实战"组成两日连训——第一天做 Skill（轻量、纯提示词工程），第二天做 MCP（重量、真协议工程），形成"从提示词到工具服务"的完整能力跃迁。
- **难度锚点**：本教程属**中级偏上**，难点不在语法而在"工程化心智"——stdout 隔离、错误返回 vs 抛异常、分页、作用域选择、2026 规范前瞻，这些都是踩坑后才痛悟的细节。

---

## 参考来源

1. [MCP TypeScript SDK - GitHub](https://github.com/modelcontextprotocol/typescript-sdk) — 官方 SDK 源码、`McpServer` 高层 API 与 Zod 用法基准
2. [Connect Claude Code to tools via MCP - Claude Code Docs](https://code.claude.com/docs/en/mcp) — 三作用域（local/project/user）与 `.mcp.json` 官方约定
3. [Debugging - Model Context Protocol](https://modelcontextprotocol.io/docs/tools/debugging) — MCP Inspector 官方调试指南
4. [SEP-2577: Deprecate Roots, Sampling, and Logging](https://modelcontextprotocol.io/seps/roots-sampling-and-logging) — 2026 年特性弃用与迁移窗口
5. [How to Build an MCP Server (Python & TypeScript 2026)](https://techsy.io/en/blog/how-to-build-an-mcp-server) — 双语言对照与 transports 实践参考

> [!tip] 51学通信知识星球
> **星主：爱卫生**（微信：gprshome201101）——公众号「51学通信」创办者，19 年通信行业运维与讲师经验，著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书（各大电商平台有售）。
> **星球定位**：深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域，主打运维岗位与信令分析。
> **当前规模**：5200 篇主题 / 990 个回答，198 元/年。
> **加入福利**：加入星球后请加爱卫生微信，拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题，另有群内分享。
> **AI 加持**：51学通信知识星球已作为 skill 接入 WorkBuddy，使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例，做总结、找文档和 PPT 等场景全覆盖。

![51学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
