本教程面向已经理解 MCP 三原语(工具 / 资源 / 提示词)基本概念、但希望亲手"接驳一个真实业务系统"的开发者。我们将以 GitHub Issues REST API 为蓝本,用官方 TypeScript SDK 的 McpServer 高层 API,构建一个可被 Claude Code 直接调用的 MCP 服务器,完整覆盖接口设计 → 输入校验与错误处理 → MCP Inspector 联机调试 → npm 打包 → 接入 Claude Code 这条真实工程闭环。学完后你能把任意带鉴权的 REST API 安全、可观测地暴露给大模型。
node -v、npm -v)async/await 与 fetchIssues: Read / Issues: Write)最常见的失败是把 REST 端点 1:1 暴露成工具——这会让模型面对几十个语义重叠的工具,触发率反而下降。正确做法是站在"任务"而非"HTTP 动词"的视角抽象。
围绕"管理一个仓库的 Issues"这个真实任务,我们收敛出 4 个工具:
| 工具名 | 对应的 REST 端点 | 设计意图 |
|---|---|---|
list_issues |
GET /repos/{owner}/{repo}/issues |
浏览与检索,只读、可分页 |
get_issue |
GET /repos/{owner}/{repo}/issues/{number} |
查单条详情,含评论摘要 |
create_issue |
POST /repos/{owner}/{repo}/issues |
写操作,需要谨慎的必填校验 |
search_issues |
GET /search/issues |
跨仓库检索,体现代理价值 |
设计原则有三条:
create_issue 把标题/正文/标签打包成一个调用,而不是让模型先 create 再 add_labels。flowchart LR
U[用户: 帮我看看张三提的bug] --> CC[Claude Code]
CC -->|tools/call search_issues| MCP[mcp-github-issues]
MCP -->|GET /search/issues| GH[GitHub REST API]
GH -->|JSON| MCP
MCP -->|结构化文本| CC
CC --> U
新建项目并安装官方 SDK。注意 TypeScript SDK 的入口分布在多个子路径下,全部走 .js 后缀的 ESM 子模块。
mkdir mcp-github-issues && cd mcp-github-issues
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx esbuild
npx tsc --init
package.json 关键字段(后续打包发布会复用):
{
"name": "mcp-github-issues",
"version": "0.1.0",
"type": "module",
"bin": { "mcp-github-issues": "./dist/index.js" },
"files": ["dist"],
"scripts": {
"build": "tsc",
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js",
"dev": "tsx src/index.ts",
"inspector": "npx @modelcontextprotocol/inspector node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.23.0"
}
}
预期结果:npm run dev 应静默启动(stdio 服务器不打印任何 stdout,因为 stdout 是协议通道)。常见问题:如果你看到日志输出,说明你把 console.log 写到了 stdout——必须改用 console.error 或 SDK 的 logging 能力,否则会污染 JSON-RPC 帧。
入口文件 src/index.ts,使用 McpServer 高层 API:它自动帮你处理 tools/list,并把你用 Zod 定义的 schema 转成 JSON Schema 暴露给客户端。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const TOKEN = process.env.GITHUB_TOKEN;
if (!TOKEN) {
console.error("[fatal] 缺少 GITHUB_TOKEN 环境变量");
process.exit(1);
}
const API = "https://api.github.com";
// 统一的请求封装:注入鉴权、处理速率限制、抛出结构化错误
async function gh(path: string, init: RequestInit = {}) {
const res = await fetch(`${API}${path}`, {
...init,
headers: {
Authorization: `Bearer ${TOKEN}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
});
if (res.status === 403 && res.headers.get("x-ratelimit-remaining") === "0") {
const reset = res.headers.get("x-ratelimit-reset");
throw new Error(`GitHub 速率限制已耗尽,将于 ${reset} (epoch) 重置`);
}
if (!res.ok) {
const body = await res.text();
throw new Error(`GitHub API ${res.status}: ${body.slice(0, 200)}`);
}
return res.json();
}
const server = new McpServer({
name: "mcp-github-issues",
version: "0.1.0",
});
// 工具 1:列出某仓库的开放 Issues
server.tool(
"list_issues",
"列出指定 GitHub 仓库最近开放的 Issues,按创建时间倒序。用于'看看最近有哪些 issue''某人提了什么'等浏览类任务。",
{
owner: z.string().describe("仓库所有者,如 vercel"),
repo: z.string().describe("仓库名,如 next.js"),
limit: z.number().int().min(1).max(50).default(10).describe("返回条数,1-50"),
},
async ({ owner, repo, limit }) => {
const data = await gh(
`/repos/${owner}/${repo}/issues?state=open&per_page=${limit}&sort=created&direction=desc`
);
const rows = (data as any[])
.filter((i) => !("pull_request" in i)) // 排除 PR
.map((i) => `#${i.number} [${(i.labels ?? []).map((l: any) => l.name).join(",")}] ${i.title} (@${i.user.login})`);
return {
content: [{ type: "text", text: rows.join("\n") || "无开放 Issue" }],
};
}
);
// 工具 2:创建 Issue(写操作,description 必须明示副作用)
server.tool(
"create_issue",
"在指定 GitHub 仓库创建一个新 Issue。属于写操作,会真实写入仓库,调用前应与用户确认。",
{
owner: z.string(),
repo: z.string(),
title: z.string().min(1).max(256).describe("Issue 标题,必填"),
body: z.string().max(65536).optional().describe("Issue 正文 Markdown"),
labels: z.array(z.string()).optional().describe("标签列表"),
},
async ({ owner, repo, title, body, labels }) => {
const created = await gh(`/repos/${owner}/${repo}/issues`, {
method: "POST",
body: JSON.stringify({ title, body, labels }),
});
return {
content: [{ type: "text", text: `已创建 #${(created as any).number}: ${(created as any).html_url}` }],
};
}
);
// search_issues / get_issue 同理,省略以节省篇幅
const transport = new StdioServerTransport();
await server.connect(transport);
核心要点:
gh() 是整个封装的灵魂——把鉴权、API 版本头、速率限制、错误归一都收敛在一处,工具函数只关心业务语义。description 字段会直接进入 JSON Schema 被模型读到。REST→MCP 的封装最容易在生产环境翻车的是错误处理。推荐建立三层防线:
min/max/enum 在 SDK 内部自动执行,非法入参根本进不了你的 handler。gh() 前做语义校验,例如 labels 是否在仓库允许的标签集合内。gh() 把 4xx/5xx/限流统一翻译成人类可读的 Error。SDK 会把 handler 抛出的 Error 自动包装成 isError: true 的工具结果返回给客户端,模型据此向用户解释失败原因。关键原则:永远不要让异常沉默吞掉——把原始信息(去掉敏感头)透传给模型,模型才能自我纠正重试。
flowchart TB
C[tools/call 请求] --> Z{Zod 校验}
Z -->|失败| ERR1[自动 isError 返回]
Z -->|通过| B{业务前置校验}
B -->|失败| ERR2[抛出带建议的 Error]
B -->|通过| N[gh 网络层]
N -->|限流/4xx/5xx| ERR3[归一化错误信息]
N -->|2xx| OK[结构化文本返回]
这是整个闭环里最高频被忽视、却最能省时间的环节。MCP Inspector 是官方的可视化调试器,能在 Claude Code 之外独立验证你的服务器是否正确握手、schema 是否合法、工具是否真能跑通。
启动方式(在项目根目录):
npm run build
npx @modelcontextprotocol/inspector node dist/index.js
Inspector 会启动一个本地 Web 服务并自动打开浏览器。首次连接时需要把环境变量填入 Inspector 的 Environment 面板(例如 GITHUB_TOKEN=ghp_xxx),否则你的 gh() 会因缺 token 直接退出。
调试工作流按四个面板依次推进:
initialize 握手成功,看到服务器名与版本号。若卡在这里,通常是 stdout 被日志污染。owner=vercel、repo=next.js、limit=3,点 Run,观察返回的结构化内容。典型故障与对策:
| 现象 | 根因 | 解决 |
|---|---|---|
握手成功但 tools/list 为空 |
用了低层 Server 却没注册 ListToolsRequestSchema |
改用高层 McpServer,或补全 handler |
| 返回乱码导致 Inspector 崩溃 | handler 里 console.log 污染了 stdout |
全部改 console.error |
调用报 isError 但无细节 |
gh() 把错误吞成空字符串 |
确保 throw new Error(body) 带上下文 |
| 偶发 403 | GitHub 速率限制 | 看 x-ratelimit-remaining 头,加退避 |
生产排障小贴士:MCP 的 host-client-server 三方模型意味着错误可能出在任意一侧。Inspector 的价值在于隔离掉 host(Claude Code)这一变量——如果 Inspector 里工具正常,问题就在 Claude Code 的配置或上下文,而非你的服务器。
调试通过后,用 esbuild 打成单文件,避免发布产物里带一堆相对路径依赖:
npm run bundle
随后有两种接入方式。
方式 A:本地直连(开发期)
在项目根目录(或任意工作目录)创建 .mcp.json,把它提交到版本库,团队成员克隆后即可共享同一套工具:
{
"mcpServers": {
"github-issues": {
"command": "node",
"args": ["/绝对路径/mcp-github-issues/dist/index.js"],
"env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" }
}
}
}
注意:env 里用 ${VAR} 引用环境变量,避免把 token 硬编码进仓库。
方式 B:CLI 注册(更灵活)
# 注册到当前项目作用域(写入 .mcp.json)
claude mcp add github-issues node ./dist/index.js \
-e GITHUB_TOKEN=$GITHUB_TOKEN --scope project
# 或注册到用户作用域(全局可用)
claude mcp add github-issues node ./dist/index.js \
-e GITHUB_TOKEN=$GITHUB_TOKEN --scope user
三种作用域的取舍:
| 作用域 | 存储位置 | 是否随仓库共享 | 适用场景 |
|---|---|---|---|
| local(默认) | 用户目录的项目段 | 否 | 个人实验 |
| project | .mcp.json |
是(入 VCS) | 团队统一工具集 |
| user | ~/.claude.json |
否 | 个人跨项目复用 |
发布到 npm 后的最终形态:先在 package.json 确认 bin 与 files 字段,然后:
npm publish --access public
其他人只需一行接入,无需关心你的源码与构建:
{
"mcpServers": {
"github-issues": {
"command": "npx",
"args": ["-y", "mcp-github-issues@latest"],
"env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" }
}
}
}
验证接入:进入 Claude Code 执行 /mcp,应看到 github-issues 状态为 connected,并列出 4 个工具。随后直接发问:"帮我看看 vercel/next.js 最近 3 个 issue"——模型应自动调用 list_issues。
把上述 4 个工具组合,就能完成单靠 REST 调用难以一次性搞定的任务。在 Claude Code 里输入:
帮我统计
anthropics/claude-code本周新增的 bug 类 Issue,按负责人分组,生成周报草稿,并以 Markdown 写入weekly.md。
模型的内部编排大致为:
sequenceDiagram
participant U as 用户
participant M as Claude Code
participant S as mcp-github-issues
participant G as GitHub API
U->>M: 生成本周 bug 周报
M->>S: search_issues label:bug created:>=本周
S->>G: GET /search/issues
G-->>S: 30 条 JSON
S-->>M: 结构化文本
M->>M: 按负责人分组聚合
M->>U: 写入 weekly.md(文件系统工具)
这个案例体现了 MCP 封装的真实价值:模型不必知道 REST 端点、不必处理鉴权与分页、不必解析 GitHub 的 JSON 结构——你用 4 个语义化工具把这些复杂性封装在了服务器一侧。如果以后 GitHub 改了 API,你只需在 gh() 一处适配,模型与用户的体验完全不变。
完成闭环后,可沿三个方向继续打磨:
server.sendLoggingMessage() 把请求耗时、剩余配额作为日志通知发给 host,在 Claude Code 里可观测。notifications/progress,让模型感知长任务进度,避免超时误判。get_issue 同时暴露为资源模板 issues://{owner}/{repo}/{number},让用户可以 @ 引用具体 Issue 作为上下文,与工具调用形成互补。McpServer 与低层 Server 的 examples 目录.mcp.json、claude mcp add 与作用域的官方说明| 维度 | 评估 |
|---|---|
| 适合的训练营类型 | 后端开发营 / AI 应用工程营 / DevOps 自动化营。需要学员具备一定后端与 API 基础,不适合纯零基础营 |
| 建议前置 | 已完成"三原语取舍"类课程,能独立读懂 TypeScript |
| 预计教学时长 | 6–8 学时:设计 1h + 实现 2h + 调试 2h + 打包接入 1h + 综合实战 1–2h |
| 实操比重 | 高,约 70% 时间为编码与调试,适合工作坊形式 |
| 评估方式 | 学员各自封装一个不同的公开 API(如 Jira、Notion、Linear),用 Inspector 演示通过、并接入 Claude Code 完成一个组合任务作为结业作业 |
| 常见踩坑预警 | stdout 污染、token 硬编码、写工具未做确认、schema description 过于简略导致触发率低——建议在教学中作为反面案例集中讲解 |
McpServer API 与 Zod 集成的官方实现[!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 等场景全覆盖。
