<!-- title: TS官方SDK打造可发布的MCP服务器 -->

## 概述

本教程面向已熟悉 TypeScript 开发、想用 MCP 官方参考实现（`@modelcontextprotocol/sdk`）打造自有工具链的工程师。Go、Rust、Python 的 MCP 服务器教程已不鲜见，但 TypeScript SDK 是 Anthropic 维护的**参考实现**——协议变更最先落地、API 最完整、社区生态最大（月下载量近亿）。我们将从零构建一个 `project-snapshot` 服务器：用 **Resources** 暴露项目元数据，用 **Tools** 执行只读分析，用 **Prompts** 内置可复用提示，最后打包到 npm、通过 `npx` 一行接入 Claude Code，并支持 stdio 与 Streamable HTTP 双传输形态。

## 前置条件

- Node.js 20+ 与 npm（`node -v` 应 ≥ 20）
- TypeScript 5.x 基础，了解 ES Modules（`type: "module"`）
- 已安装 Claude Code（用于联调与接入验证）
- 一个本地 Git 仓库作为"被分析对象"（实战案例需要）
- 对 MCP 三原语（Tools / Resources / Prompts）有概念性了解即可

## 一、理解两层 API：低层 Server 与高层 McpServer

官方 TypeScript SDK 提供两种风格的 API，理解它们的分工是写出干净服务器的前提。

- **低层 `Server` 类**（`@modelcontextprotocol/sdk/server/index.js`）：直接映射协议消息，需要你手写 `setRequestHandler` 注册 `"tools/call"`、`"resources/read"` 等处理器，并自行把 Zod schema 转成 JSON Schema。控制力最强，代码也最冗长。
- **高层 `McpServer` 类**（`@modelcontextprotocol/sdk/server/mcp.js`）：在 `Server` 之上封装出 `server.tool()` / `server.resource()` / `server.prompt()` 三个声明式方法，**自动把 Zod schema 编译成 JSON Schema**，自动处理协议握手、能力声明、错误包装。绝大多数场景应从 `McpServer` 起步。

一句话原则：**用高层 API 表达"我提供什么"，由 SDK 负责协议细节**。本教程全程使用高层 API，仅在第九节打包时简要说明何时需要退回低层。

## 二、脚手架与项目初始化

官方提供一键脚手架，能生成带 `bin` 入口、TypeScript 配置、Inspector 联调脚本的最小工程。

```bash
npx @modelcontextprotocol/create-mcp-server@latest project-snapshot
cd project-snapshot
npm install
```

若想完全手动搭建，最小目录结构如下：

```
project-snapshot/
├── src/
│   └── index.ts        # 服务器入口（含 shebang）
├── package.json
├── tsconfig.json
└── README.md
```

`package.json` 的关键字段——`type: "module"` 决定能否用 `import`，`bin` 决定能否被 `npx` 直接执行：

```json
{
  "name": "project-snapshot",
  "version": "1.0.0",
  "type": "module",
  "bin": { "project-snapshot": "dist/index.js" },
  "files": ["dist"],
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "start": "node dist/index.js",
    "inspect": "npx @modelcontextprotocol/inspector node dist/index.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.18.0",
    "zod": "^3.25.0"
  },
  "devDependencies": {
    "typescript": "^5.9.0",
    "@types/node": "^22.0.0"
  }
}
```

`tsconfig.json` 用 Node16 模块解析，确保 `.js` 扩展名导入能正确解析：

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true
  },
  "include": ["src/**/*"]
}
```

> **常见问题**：导入路径必须写全扩展名（`from ".../server/mcp.js"`），即便源文件是 `.ts`。这是因为 `NodeNext` 模块解析按字面量解析导入路径。漏掉 `.js` 会报 `ERR_MODULE_NOT_FOUND`。

## 三、定义 Resources：把项目元数据暴露给模型

Resources 是 MCP 的只读数据原语，适合暴露"模型可能想读的上下文"。`server.resource()` 接受名称、URI（字符串或 `ResourceTemplate`）、回调。

```typescript
#!/usr/bin/env node
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { readFileSync } from "node:fs";

const server = new McpServer({
  name: "project-snapshot",
  version: "1.0.0",
});

// 静态 URI：项目清单
server.resource("manifest", "file:///package.json", async (uri) => {
  const text = readFileSync("package.json", "utf8");
  return { contents: [{ uri: uri.href, mimeType: "application/json", text }] };
});

// 动态模板：带参数的 README
const readmeTpl = new ResourceTemplate("snapshot://readme/{project}", { list: undefined });
server.resource("readme", readmeTpl, async (uri, { project }) => {
  const text = readFileSync("README.md", "utf8");
  return {
    contents: [{ uri: uri.href, mimeType: "text/markdown", text: `## ${project}\n\n${text}` }],
  };
});
```

**预期结果**：Claude Code 里输入 `/mcp` 可看到 `project-snapshot` 暴露的资源列表；问"读一下这个项目的 package.json"，模型会通过 `resources/read` 拉取而非自己用 Bash 读——区别在于这是**协议化的、带 MIME 类型与 URI 寻址**的访问，客户端可做权限边界控制。

## 四、定义 Tools：让模型执行只读分析

Tools 是"带副作用的动作"，是 MCP 最常用的原语。`server.tool(name, description, schema, handler)`：传入 Zod schema，SDK 自动生成 JSON Schema 暴露给模型。

```typescript
import { execSync } from "node:child_process";
import { z } from "zod";

// 无参数工具：schema 可省略
server.tool("list_scripts", "列出 package.json 中的 npm scripts", async () => {
  const pkg = JSON.parse(readFileSync("package.json", "utf8"));
  const scripts = Object.entries(pkg.scripts ?? {}) as [string, string][];
  const text = scripts.length
    ? scripts.map(([k, v]) => `- **${k}**: \`${v}\``).join("\n")
    : "(未定义任何 script)";
  return { content: [{ type: "text", text }] };
});

// 带参数工具：Zod 描述输入契约
server.tool(
  "count_todos",
  "统计源码中的 TODO/FIXME 数量并返回示例",
  { path: z.string().default(".").describe("搜索根目录") },
  async ({ path }) => {
    try {
      const out = execSync(`grep -rIn "TODO\\|FIXME" ${path} || true`, { encoding: "utf8" });
      const lines = out.split("\n").filter(Boolean);
      const preview = lines.slice(0, 20).join("\n");
      return {
        content: [{ type: "text", text: `共 ${lines.length} 处：\n${preview}` }],
      };
    } catch (e) {
      return { content: [{ type: "text", text: (e as Error).message }], isError: true };
    }
  },
);

server.tool(
  "recent_commits",
  "查看最近 N 条 git 提交",
  { n: z.number().int().min(1).max(50).default(10) },
  async ({ n }) => {
    const out = execSync(
      `git log -n ${n} --pretty=format:"%h %ad %s" --date=short 2>/dev/null`,
      { encoding: "utf8" },
    );
    return { content: [{ type: "text", text: out || "(非 git 仓库)" }] };
  },
);
```

**关键细节**：

1. 返回值用 `{ content: [{ type: "text" | "image" | "resource", ... }] }` 结构，与协议 `CallToolResult` 对齐。
2. 出错时设 `isError: true`，**不要抛异常**——抛异常会被 SDK 转成传输层错误，客户端可能直接断连；结构化错误让模型有机会重试或换工具。
3. `.describe()` 写进 schema 的注释会成为模型的"参数说明书"，直接影响调用准确率。
4. Tools 默认对模型"可见即 callable"，若有破坏性操作，务必在 `package.json` 同级的 Claude Code 权限层加护栏（本例全是只读，无需额外护栏）。

## 五、定义 Prompts：内置可复用提示模板

Prompts 把"团队沉淀的提示词"固化成服务器资产，模型可按名称加载、参数化填充。`server.prompt(name, schema, handler)` 返回标准 `messages` 结构。

```typescript
server.prompt(
  "release_notes",
  "根据最近提交生成面向用户的发布说明",
  { since: z.string().describe("起始 tag 或日期，例如 v1.0.0") },
  async ({ since }) => {
    const log = execSync(`git log ${since}..HEAD --pretty=format:"- %s" 2>/dev/null`, {
      encoding: "utf8",
    });
    return {
      messages: [
        {
          role: "user",
          content: {
            type: "text",
            text:
              "把以下提交整理成面向终端用户的发布说明，按「新功能 / 问题修复 / 其他」" +
              "分类，语气友好、避免技术黑话：\n\n" + (log || "(无新提交)"),
          },
        },
      ],
    };
  },
);
```

**预期结果**：在 Claude Code 中可调用 `@project-snapshot release_notes since:v1.2.0`，模型直接拿到填充好的提示并继续生成。与把提示写进 Skill 的区别：Prompt 由服务器统一版本管理、可参数化、跨客户端（Claude Desktop / Code / Cursor）通用。

## 六、传输层：从 stdio 到 Streamable HTTP 双形态

MCP 的传输层与业务逻辑完全解耦——同一个 `McpServer` 可挂到不同 transport。**stdio 是 npm/npx 分发的默认形态**（Claude Code 启动子进程、通过标准输入输出通信）；**Streamable HTTP 用于远程部署**（多客户端共享、可加 OAuth2、可水平扩展）。

stdio 形态（默认）：

```typescript
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

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

Streamable HTTP 形态（用 Express 暴露 `/mcp` 端点）：

```typescript
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

if (process.argv.includes("--http")) {
  const app = express();
  app.post("/mcp", async (req, res) => {
    const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
    await server.connect(t);
    await t.handleRequest(req, res, req.body);
  });
  app.listen(3000, () => console.error("HTTP MCP on :3000/mcp"));
} else {
  await server.connect(new StdioServerTransport());
}
```

> **常见问题**：
> - **stdio 模式下不要 `console.log`**：标准输出是协议通道，任何日志都会污染 JSON-RPC 帧，导致客户端解析失败。用 `console.error`（走 stderr）或 SDK 的 logging facility（`server.sendLoggingMessage`）。
> - **Streamable HTTP 无状态模式**（`sessionIdGenerator: undefined`）适合短连接与无状态部署；需要会话保持时实现自定义 `sessionIdGenerator` 返回唯一 ID。
> - 远程 HTTP 服务器务必加认证（参考已覆盖的 OAuth2 远程服务器教程），否则等于把工具能力暴露在公网。

## 七、接入 Claude Code：三种 scope 与 .mcp.json

构建出产物后，用 `claude mcp add` 注册。Claude Code 有三种配置 scope，决定配置写到哪里、谁能共享：

```bash
# 1) local（默认）：仅你本人、当前项目可见，写进用户级项目设置
claude mcp add project-snapshot -- node /绝对路径/dist/index.js

# 2) project：写入仓库根 .mcp.json，随 git 提交，团队共享
claude mcp add project-snapshot --scope project -- npx -y project-snapshot

# 3) user：写入 ~/.claude.json，你在所有项目都能用
claude mcp add project-snapshot --scope user -- npx -y project-snapshot

# 验证与管理
claude mcp list
claude mcp get project-snapshot
claude mcp remove project-snapshot
```

**`--` 的作用**：把其后所有参数原样作为启动命令，避免 Claude Code 的 CLI flag 与服务器参数混淆。stdio 服务器尤其要注意。

`--scope project` 生成的 `.mcp.json`（可提交到 VCS，团队成员 clone 后 Claude Code 首次启动会提示授权）：

```json
{
  "mcpServers": {
    "project-snapshot": {
      "command": "npx",
      "args": ["-y", "project-snapshot"],
      "env": {}
    }
  }
}
```

**预期结果**：注册成功后，在 Claude Code 内输入 `/mcp` 看到 `project-snapshot: connected`，工具以 `mcp__project-snapshot__count_todos` 的命名空间暴露。直接说"统计一下 TODO"即可触发。

## 八、用 Inspector 全链路调试

Inspector 是官方可视化调试器，能脱离任何客户端直接驱动你的服务器、观察每个请求/响应。

```bash
npm run inspect
# 等价于：npx @modelcontextprotocol/inspector node dist/index.js
```

浏览器打开后可：手动调用每个 Tool 并查看 `CallToolResult` 原始 JSON；列出/读取 Resources；加载 Prompt 并查看填充后的 `messages`；切换 transport（stdio / Streamable HTTP）。**强烈建议在接入 Claude Code 之前先用 Inspector 把每个原语跑通**——客户端侧的报错往往信息不透明，Inspector 能精确隔离"是我的服务器错了，还是客户端配置错了"。

## 九、打包发布到 npm，让所有人 npx 一行接入

把服务器发布到 npm 后，任何用户无需 clone 代码，一行命令即可加载。关键在于 `bin` 字段 + shebang + `files` 白名单。

```bash
# 1) 确认入口可执行
chmod +x src/index.ts          # shebang 需要文件可执行权限（构建产物 dist/index.js 同理）
npm run build                  # 产出 dist/

# 2) 本地验证 npx 能跑通（用 pack 模拟发布）
npm pack                       # 生成 project-snapshot-1.0.0.tgz
npx ./project-snapshot-1.0.0.tgz   # 应启动 stdio 服务器（阻塞等待输入即正常）

# 3) 登录并发布
npm login
npm publish                    # 首次发布；后续用 npm version patch && npm publish

# 4) 任意机器一行接入 Claude Code
claude mcp add project-snapshot -- npx -y project-snapshot
```

发布检查清单：
- `package.json` 的 `name` 全局唯一；`version` 走 SemVer；`repository` / `homepage` / `description` 写全（npm 搜索与 MCP 目录收录都依赖这些）。
- `files: ["dist"]` 确保只发布构建产物，不泄露 `src` / 测试。
- `bin` 指向 `dist/index.js`，且该文件首行是 `#!/usr/bin/env node`。
- 若用了原生依赖，声明 `engines: { node: ">=20" }` 并提供 `optionalDependencies` 平台包。

## 实战案例：project-snapshot 完整可运行代码

把前面碎片整合成一个可直接 `npm run build && node dist/index.js` 启动的完整文件。新建 `src/index.ts`：

```typescript
#!/usr/bin/env node
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { readFileSync } from "node:fs";
import { execSync } from "node:child_process";
import { z } from "zod";

const server = new McpServer({ name: "project-snapshot", version: "1.0.0" });

// ---------- Resources ----------
server.resource("manifest", "file:///package.json", async (uri) => ({
  contents: [
    { uri: uri.href, mimeType: "application/json", text: readFileSync("package.json", "utf8") },
  ],
}));

const readmeTpl = new ResourceTemplate("snapshot://readme/{project}", { list: undefined });
server.resource("readme", readmeTpl, async (uri, { project }) => ({
  contents: [
    { uri: uri.href, mimeType: "text/markdown", text: `## ${project}\n\n` + readFileSync("README.md", "utf8") },
  ],
}));

// ---------- Tools ----------
server.tool("list_scripts", "列出 npm scripts", async () => {
  const pkg = JSON.parse(readFileSync("package.json", "utf8"));
  const s = Object.entries(pkg.scripts ?? {}) as [string, string][];
  return { content: [{ type: "text", text: s.length ? s.map(([k, v]) => `- ${k}: ${v}`).join("\n") : "(无)" }] };
});

server.tool(
  "count_todos",
  "统计 TODO/FIXME",
  { path: z.string().default(".") },
  async ({ path }) => {
    try {
      const out = execSync(`grep -rIn "TODO\\|FIXME" ${path} || true`, { encoding: "utf8" });
      const lines = out.split("\n").filter(Boolean);
      return { content: [{ type: "text", text: `共 ${lines.length} 处\n` + lines.slice(0, 20).join("\n") }] };
    } catch (e) {
      return { content: [{ type: "text", text: (e as Error).message }], isError: true };
    }
  },
);

server.tool(
  "recent_commits",
  "最近 N 条提交",
  { n: z.number().int().min(1).max(50).default(10) },
  async ({ n }) => ({
    content: [
      { type: "text", text: execSync(`git log -n ${n} --pretty=format:"%h %ad %s" --date=short 2>/dev/null`, { encoding: "utf8" }) || "(非 git 仓库)" },
    ],
  }),
);

// ---------- Prompts ----------
server.prompt(
  "release_notes",
  { since: z.string().describe("起始 tag/日期，如 v1.0.0") },
  async ({ since }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text:
            "把以下提交整理成面向用户的发布说明，按「新功能/修复/其他」分类，语气友好：\n\n" +
            execSync(`git log ${since}..HEAD --pretty=format:"- %s" 2>/dev/null`, { encoding: "utf8" }) || "(无新提交)",
        },
      },
    ],
  }),
);

// ---------- 传输层 ----------
await server.connect(new StdioServerTransport());
```

构建并接入：

```bash
npm install && npm run build
claude mcp add project-snapshot -- node "$(pwd)/dist/index.js"
# 在仓库目录内启动 claude，输入：统计一下当前项目的 TODO，并列出最近 5 条提交
```

## 工作流程图

```mermaid
flowchart TD
    A[npx create-mcp-server 脚手架] --> B[配置 package.json / tsconfig]
    B --> C[McpServer 声明三原语]
    C --> C1[Resources 只读元数据]
    C --> C2[Tools 只读分析]
    C --> C3[Prompts 提示模板]
    C1 & C2 & C3 --> D{选择传输层}
    D -->|本地分发| E[StdioServerTransport]
    D -->|远程部署| F[StreamableHTTPServerTransport]
    E --> G[npm run inspect 全链路调试]
    F --> G
    G --> H[npm pack 本地验证]
    H --> I[npm publish 发布]
    I --> J[claude mcp add -- npx -y 接入]
    J --> K[Claude Code 内 /mcp 验证可用]
```

## 常见问题排坑

- **`ERR_MODULE_NOT_FOUND: .../mcp.js`**：`tsconfig` 未用 `Node16` 模块解析，或导入路径漏了 `.js` 扩展名。
- **服务器启动后 Claude Code 一直 "failed to connect"**：90% 是 stdio 模式里写了 `console.log`。改成 `console.error` 或 `server.sendLoggingMessage({ level: "info", data: "..." })`。
- **工具在 Claude Code 中看不到**：确认 `claude mcp get <name>` 显示 `connected`；若显示 `failed`，用 `claude --mcp-debug` 启动看 stderr；Inspector 单独跑 `node dist/index.js` 验证服务器本身无误。
- **Zod schema 没生效、模型乱传参**：检查是否把 schema 作为 `server.tool` 的**第三个**参数（描述在前、schema 在中）；`.default()` 提供的参数可省略。
- **`npx -y` 接入很慢**：首次会下载包。固定版本 `npx -y project-snapshot@1.0.0` 可避免每次拉取 latest 的网络开销。
- **发布后 `npx project-snapshot` 报权限拒绝**：`dist/index.js` 缺可执行位，`chmod +x dist/index.js` 后重新 `npm publish`。

## 延伸阅读

- 官方 TypeScript SDK 仓库与示例：https://github.com/modelcontextprotocol/typescript-sdk
- SDK 高层 API 文档（含 ResourceTemplate 列表回调）：https://ts.sdk.modelcontextprotocol.io/documents/server.html
- MCP 协议核心概念（三原语、传输、生命周期）：https://modelcontextprotocol.io/docs/sdk
- Claude Code 接入 MCP 官方文档（scope、`.mcp.json`、Tool Search）：https://code.claude.com/docs/en/mcp
- MCP Inspector 调试指南：https://github.com/modelcontextprotocol/inspector

## 训练营适用性评估

本教程适合**"AI 工程化 / 开发者工具"方向的进阶训练营**，难度定位为中阶（需 TypeScript 与命令行基础）。建议教学时长 3–4 小时：1 小时讲两层 API 与三原语模型，1.5 小时动手实现 + Inspector 调试，1 小时打包发布与团队 `.mcp.json` 共享演练。可作为"工具生态构建"模块的核心实验，前后衔接"封装现有 API"（基础）与"OAuth2 远程服务器"（高级）。若学员以 Python/Go 为主，本教程的**协议理解与工程化流程**仍可直接迁移，仅需替换 SDK 语法。

## 参考来源

1. [modelcontextprotocol/typescript-sdk](https://github.com/modelcontextprotocol/typescript-sdk) — MCP 官方 TypeScript 参考实现，含 McpServer 高层 API、ResourceTemplate、传输层源码
2. [Server Guide | MCP TypeScript SDK](https://ts.sdk.modelcontextprotocol.io/documents/server.html) — 官方服务器构建指南，resources.subscribe 能力与动态资源列表机制
3. [Connect Claude Code to tools via MCP](https://code.claude.com/docs/en/mcp) — Claude Code 的 `claude mcp add` 三种 scope、`.mcp.json` 共享、`--` 参数分隔官方说明
4. [Build an MCP Server with TypeScript: 2026 Tutorial](https://dev.to/jangwook_kim_e31e7291ad98/build-an-mcp-server-with-typescript-2026-tutorial-1ipk) — 2026 年 TS SDK 全原语实战教程与生态数据（月下载近亿）
5. [MCP TypeScript SDK: A Complete Guide](https://blog.agentailor.com/posts/mcp-typescript-sdk-complete-guide) — 高层 vs 低层 API 对比、Zod schema 编译、Streamable HTTP 部署进阶

> [!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)
