首页 › tutorials › 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 工具。


前置条件

不需要你此前写过 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 接入本地工具的标准方式。

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。


第二步:脚手架与依赖

新建项目目录并初始化:

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 正确驱动的核心):

{
  "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 模块解析:

{
  "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 + 处理函数":

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

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

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 类型,善用它们能显著降低模型的理解成本:

// 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
          ),
        },
      ],
    };
  }
);

设计要点:


第六步:stdio 传输的工程化

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

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,让产物能直接当命令跑:

npm run build

确认 dist/index.js 顶部有 #!/usr/bin/env node(tsup 的 --banner 可注入;或在 src/index.ts 顶部手写一行 #!/usr/bin/env node,构建时会保留)。验证:

./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(全局) 个人跨项目通用工具 否
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 随仓库走:

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

这会生成项目根的 .mcp.json:

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

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

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 作用域存放。

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

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

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

常见问题与解决:


第九步:用 MCP Inspector 调试

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

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 代码统计服务器

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

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

src/index.ts 完整版(合并第一到第六步的代码,去掉重复 import)。能力清单:

跑通流程:

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"团队级工具复用"的核心价值。


工作流程总览

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 冒烟测试]

延伸阅读


训练营适用性评估


参考来源

  1. MCP TypeScript SDK - GitHub — 官方 SDK 源码、McpServer 高层 API 与 Zod 用法基准
  2. Connect Claude Code to tools via MCP - Claude Code Docs — 三作用域(local/project/user)与 .mcp.json 官方约定
  3. Debugging - Model Context Protocol — MCP Inspector 官方调试指南
  4. SEP-2577: Deprecate Roots, Sampling, and Logging — 2026 年特性弃用与迁移窗口
  5. How to Build an MCP Server (Python & TypeScript 2026) — 双语言对照与 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学通信知识星球海报-扫码加入