首页 › tutorials › 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 双传输形态。

前置条件

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

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

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

二、脚手架与项目初始化

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

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 直接执行:

{
  "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 扩展名导入能正确解析:

{
  "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)、回调。

#!/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 暴露给模型。

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 结构。

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 形态(默认):

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

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

Streamable HTTP 形态(用 Express 暴露 /mcp 端点):

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,决定配置写到哪里、谁能共享:

# 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 首次启动会提示授权):

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

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

八、用 Inspector 全链路调试

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

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 白名单。

# 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:

#!/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());

构建并接入:

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

工作流程图

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 验证可用]

常见问题排坑

延伸阅读

训练营适用性评估

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

参考来源

  1. modelcontextprotocol/typescript-sdk — MCP 官方 TypeScript 参考实现,含 McpServer 高层 API、ResourceTemplate、传输层源码
  2. Server Guide | MCP TypeScript SDK — 官方服务器构建指南,resources.subscribe 能力与动态资源列表机制
  3. Connect Claude Code to tools via MCP — Claude Code 的 claude mcp add 三种 scope、.mcp.json 共享、-- 参数分隔官方说明
  4. Build an MCP Server with TypeScript: 2026 Tutorial — 2026 年 TS SDK 全原语实战教程与生态数据(月下载近亿)
  5. MCP TypeScript SDK: A 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学通信知识星球海报-扫码加入