本教程带你用官方 @modelcontextprotocol/sdk(TypeScript 版,v1.29.0)把一个真实的公开 REST API(GitHub API)封装成 MCP 服务器,让 Claude Code 能直接调用。你将掌握三步核心模式(建实例→注册工具→接传输)、zod v4 强类型校验与分页、InMemoryTransport 同进程测试、StdioServerTransport 接入 Claude Code、以及MCP Inspector 可视化调试。适合已会用 Node.js/TypeScript、想让 Claude Code 拥有自定义数据能力的开发者,全程可运行代码,约 60 分钟跑通。
interface、泛型、async/await)本教程与已发布的「Python 造 MCP 三原语」「Go 造 MCP」「底层 API 造 MCP」完全互补:本篇聚焦 TypeScript SDK 的高层
McpServerAPI + zod v4 + REST 封装 + 同进程测试闭环,是上述几篇未覆盖的角度。
在动手前,先记住这张心智模型。官方 TypeScript SDK 把一个 MCP 服务器的生命周期压缩成三步,理解它你就掌握了 90% 的 MCP 开发:
flowchart TD
A["Step 1<br/>new McpServer(name, version)"] --> B["Step 2<br/>server.tool(name, desc, zodSchema, handler)"]
B --> C["Step 3<br/>server.connect(transport)"]
C --> D1["InMemoryTransport<br/>同进程自动化测试"]
C --> D2["StdioServerTransport<br/>接入 Claude Code / Desktop"]
C --> D3["Streamable HTTP / SSE<br/>远程团队共享部署"]
name 和 version 是客户端识别服务器的唯一标识。server.tool() 注册,第二个参数「描述」是模型决定何时调用这个工具的唯一依据——这是新手最容易忽视、却最影响效果的一环。StdioServerTransport,远程用 HTTP/SSE,测试用 InMemoryTransport。MCP 本质是 JSON-RPC 2.0 协议:Host(Claude Code)作为 Client,通过传输层把你的 Server 当作一个子进程拉起,双方按行交换 JSON 消息。SDK 已经把帧解析、握手、能力协商全包了,你只需写业务 handler。
mkdir gh-mcp && cd gh-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init
预期输出关键行:
added 92 packages, and audited 93 packages in 2s
found 0 vulnerabilities
这里你会拿到 @modelcontextprotocol/[email protected] 与 [email protected]。然后改 package.json,两个坑必须避开:
{
"name": "gh-mcp",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "tsx test/in-memory.test.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
}
}
坑一(致命):"type": "module" 不可省。SDK 是纯 ESM 包,少了它 Node 无法解析 @modelcontextprotocol/sdk/server/mcp.js 这类子路径导入。
tsconfig.json 关键项:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"outDir": "dist",
"esModuleInterop": true,
"skipLibCheck": true
}
}
moduleResolution 用 bundler 或 node16 都行,但不要用老式 node,否则解析不了 ESM 子路径。
get_repo_info新建 src/server.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// Step 1: 建实例
const server = new McpServer({
name: "gh-mcp",
version: "1.0.0",
});
// Step 2: 注册第一个工具
server.tool(
"get_repo_info", // 工具名
"Fetch a GitHub repository's basic info (stars, forks, description, language) by owner/repo, e.g. 'facebook/react'.", // 描述:模型靠它决定何时调用
{
owner: z.string().describe("GitHub owner, e.g. 'facebook'"),
repo: z.string().describe("Repository name, e.g. 'react'"),
},
async ({ owner, repo }) => {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: { "Accept": "application/vnd.github+json", "User-Agent": "gh-mcp" },
});
if (!res.ok) {
return {
content: [{ type: "text", text: `API error: ${res.status} ${res.statusText}` }],
isError: true,
};
}
const r = await res.json() as any;
const summary = [
`Full name: ${r.full_name}`,
`Description: ${r.description ?? "(none)"}`,
`Language: ${r.language ?? "unknown"}`,
`Stars: ${r.stargazers_count} Forks: ${r.forks_count} Open issues: ${r.open_issues_count}`,
`Default branch: ${r.default_branch}`,
`Homepage: ${r.homepage ?? "(none)"}`,
].join("\n");
return { content: [{ type: "text", text: summary }] };
}
);
// Step 3: 接入 stdio 传输(本地部署模式)
const transport = new StdioServerTransport();
await server.connect(transport);
server.tool() 的四个参数依次是:工具名、描述、zod schema、async handler。handler 必须返回标准结构:
{ content: [{ type: "text", text: "..." }] }
content 数组里每一项都有 type,除了 text 还可以是 image(带 base64)或 resource(引用资源),但 90% 场景 text 足够。
预期结果:npm run build && node dist/server.js 启动后,进程会阻塞等待,没有任何输出——这是对的。stdio 服务器在等 Client 发 JSON-RPC 消息。Ctrl+C 退出即可。
坑二(最常见):永远不要在 handler 或顶层用 console.log()。stdout 是 JSON-RPC 专用通道,任何杂质输出都会污染协议流、直接破坏通信。要打日志就用 console.error()(走 stderr)或写文件:
import { appendFileSync } from "node:fs";
const log = (msg: string) => appendFileSync("debug.log", `${msg}\n`);
GitHub 搜索接口天然带分页,正好演示 zod 的进阶用法。再加一个 search_repos 工具:
server.tool(
"search_repos",
"Search public GitHub repositories by keyword. Returns name, stars, short description and URL.",
{
query: z.string().min(1).max(200).describe("Search keywords, e.g. 'state machine typescript'"),
language: z
.string()
.describe("Filter by primary language, e.g. 'typescript'. Omit for any language.")
.optional(),
limit: z
.number()
.int()
.min(1)
.max(10)
.default(5)
.describe("Max results to return (1-10)"),
page: z
.number()
.int()
.min(1)
.default(1)
.describe("Page number for pagination, 1-based"),
},
async ({ query, language, limit, page }) => {
const params = new URLSearchParams({
q: language ? `${query} language:${language}` : query,
sort: "stars",
order: "desc",
per_page: String(limit),
page: String(page),
});
const res = await fetch(`https://api.github.com/search/repositories?${params}`, {
headers: { "Accept": "application/vnd.github+json", "User-Agent": "gh-mcp" },
});
if (!res.ok) {
return {
content: [{ type: "text", text: `API error: ${res.status} ${res.statusText}` }],
isError: true,
};
}
const data = (await res.json()) as any;
const items = (data.items ?? []).slice(0, limit);
if (items.length === 0) {
return { content: [{ type: "text", text: `No results for: ${query}` }] };
}
const lines = items.map(
(it: any, i: number) =>
`${i + 1}. ${it.full_name} ⭐${it.stargazers_count}\n ${it.description ?? "(no description)"}\n ${it.html_url}`,
);
const header = `Found ${data.total_count} repos (showing page ${page}, ${items.length} results):\n\n`;
return { content: [{ type: "text", text: header + lines.join("\n\n") }] };
}
);
几个 zod v4 要点:
.default(5) 让参数可选——模型不传时用默认值。这比 .optional() 更友好,因为模型不用猜。.int().min(1).max(10) 是数值边界,SDK 会把它转成 JSON Schema 的 minimum/maximum,模型会自动遵守。.optional() 与 .nullable()、z.union() 的组合行为变了。网上的旧教程照抄会报类型错误。还有:.describe() 要写在 .optional() 之前,即 z.string().describe("...").optional(),反着写会出现类型推断异常。per_page 上限 10)。模型上下文有限,返回过长会挤掉历史对话甚至触发上下文溢出。stdio 模式不好做自动化测试(要起子进程)。SDK 提供了 InMemoryTransport,让你在同一个进程内完成 Client↔Server 的往返测试,无需任何外部依赖。新建 test/in-memory.test.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { z } from "zod";
// 复用真实 server 构建逻辑(实际项目应抽成 buildServer() 函数导入)
const server = new McpServer({ name: "gh-mcp", version: "1.0.0" });
server.tool(
"get_repo_info",
"Fetch repo info",
{ owner: z.string(), repo: z.string() },
async ({ owner, repo }) => {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: { "User-Agent": "gh-mcp" },
});
const r = (await res.json()) as any;
return {
content: [{ type: "text", text: `${r.full_name}: ⭐${r.stargazers_count}` }],
};
},
);
// 创建一对内存传输(client 端 + server 端,互相直连)
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(clientTransport);
// 1) 列出已注册工具
const { tools } = await client.listTools();
console.log("=== Tools registered ===");
tools.forEach((t) => console.log(` - ${t.name}: ${t.description}`));
// 2) 调用工具
const result = await client.callTool({
name: "get_repo_info",
arguments: { owner: "anthropics", repo: "anthropic-sdk-python" },
});
console.log("\n=== Result ===");
console.log(result.content[0].text);
await client.close();
运行 npm test,预期输出:
=== Tools registered ===
- get_repo_info: Fetch repo info
=== Result ===
anthropics/anthropic-sdk-python: ⭐2987
InMemoryTransport.createLinkedPair() 返回一对直连的传输端,绕开了 stdio/HTTP 的所有基建。这是 CI 里验证 MCP 工具行为最干净的方式——把它接进你现有的测试框架即可。
新手最容易犯的错是在 handler 里 throw new Error()。MCP 约定是返回错误,而不是抛异常。不同客户端对抛出的异常处理方式不一致,有的会直接断开连接。正确写法:
async ({ owner, repo }) => {
try {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers: { "User-Agent": "gh-mcp" },
});
if (res.status === 404) {
return {
content: [{ type: "text", text: `Repository not found: ${owner}/${repo}` }],
isError: true, // 显式标记为错误,客户端会按错误处理
};
}
if (res.status === 403) {
return {
content: [{ type: "text", text: "GitHub API rate limit hit. Wait a minute and retry." }],
isError: true,
};
}
if (!res.ok) {
return {
content: [{ type: "text", text: `API error: ${res.status} ${res.statusText}` }],
isError: true,
};
}
const r = await res.json();
return { content: [{ type: "text", text: JSON.stringify(r, null, 2) }] };
} catch (err) {
return {
content: [
{ type: "text", text: `Network error: ${err instanceof Error ? err.message : String(err)}` },
],
isError: true,
};
}
}
isError: true 让客户端(Claude Code)知道这是一次失败调用,从而决定是重试还是向用户报告。区分 404(仓库不存在,模型应换个关键词)与 403(限流,模型应等待)能让模型做出更聪明的决策。
GitHub 公开 API 未认证时每小时只有 60 次配额,开发期很容易撞墙。如果你有自己的 Token,可在请求头加 Authorization: Bearer <token>,配额提升到 5000/小时——但注意绝不要把 Token 硬编码进代码或提交进 git,从环境变量读取:
const token = process.env.GITHUB_TOKEN;
const headers: Record<string, string> = { "User-Agent": "gh-mcp" };
if (token) headers.Authorization = `Bearer ${token}`;
TypeScript 项目可以跳过编译,直接用 tsx 跑。Claude Code 提供 CLI 一键注册:
# 方式一:CLI(推荐)
claude mcp add gh-mcp -- npx tsx /绝对路径/gh-mcp/src/server.ts
# 验证已注册
claude mcp list
预期 claude mcp list 输出:
gh-mcp ✓ gh-mcp 1.0.0
- get_repo_info: Fetch a GitHub repository's basic info...
- search_repos: Search public GitHub repositories by keyword...
方式二:项目根目录 .mcp.json(适合团队共享,提交进仓库):
{
"mcpServers": {
"gh-mcp": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/gh-mcp/src/server.ts"]
}
}
}
坑四(路径):
.mcp.json里的args必须用绝对路径或可靠的相对解析。Claude Code 把 server 当子进程拉起,工作目录不一定是你期望的位置。
注册后,在 Claude Code 里直接提问即可触发工具,例如:
帮我查一下
anthropics/courses这个仓库的基本信息和星标数,再搜一下"agent eval typescript"语言为 typescript 的热门仓库,给我前 5 个。
Claude Code 会自动调用 get_repo_info 和 search_repos,把结果整理给你。用 /mcp 命令可查看连接状态与工具清单。
开发期,MCP Inspector 是 GUI 调试利器,能在浏览器里直观看到所有工具、它们的 zod schema(渲染成表单),并直接填参调用:
npx @modelcontextprotocol/inspector node dist/server.js
打开 http://localhost:5173:
owner=anthropics、repo=anthropic-cookbook,点 Run Tool,下方返回 JSON 响应。注意事项:Inspector 要求 Node 18+;首次运行要安装自身依赖,首次启动约需 30–60 秒,别以为卡死了。
sequenceDiagram
participant Dev as 开发者
participant Insp as MCP Inspector (5173)
participant Server as gh-mcp Server (stdio)
participant GH as GitHub API
Dev->>Insp: 填参数 owner/repo
Insp->>Server: JSON-RPC tools/call
Server->>GH: GET /repos/:owner/:repo
GH-->>Server: repo JSON
Server-->>Insp: { content: [{text}] }
Insp-->>Dev: 渲染响应
把前面所有片段拼成一个完整文件 src/server.ts,含 3 个工具 + 统一错误处理:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "gh-mcp", version: "1.0.0" });
// 统一的请求头构造(可选 Token)
function ghHeaders(): Record<string, string> {
const h: Record<string, string> = {
Accept: "application/vnd.github+json",
"User-Agent": "gh-mcp",
};
if (process.env.GITHUB_TOKEN) h.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
return h;
}
// 通用错误返回
function err(msg: string) {
return { content: [{ type: "text" as const, text: msg }], isError: true };
}
// 工具 1:仓库详情
server.tool(
"get_repo_info",
"Fetch a GitHub repository's basic info (stars, forks, description, language) by owner/repo.",
{ owner: z.string().describe("GitHub owner, e.g. 'facebook'"),
repo: z.string().describe("Repository name, e.g. 'react'") },
async ({ owner, repo }) => {
try {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers: ghHeaders() });
if (res.status === 404) return err(`Repository not found: ${owner}/${repo}`);
if (!res.ok) return err(`API error: ${res.status} ${res.statusText}`);
const r = (await res.json()) as any;
const text = [
`Full name: ${r.full_name}`,
`Description: ${r.description ?? "(none)"}`,
`Language: ${r.language ?? "unknown"}`,
`Stars: ${r.stargazers_count} Forks: ${r.forks_count} Issues: ${r.open_issues_count}`,
`URL: ${r.html_url}`,
].join("\n");
return { content: [{ type: "text", text }] };
} catch (e) {
return err(`Network error: ${e instanceof Error ? e.message : String(e)}`);
}
},
);
// 工具 2:搜索仓库(带分页)
server.tool(
"search_repos",
"Search public GitHub repositories by keyword with optional language filter and pagination.",
{
query: z.string().min(1).max(200).describe("Search keywords"),
language: z.string().describe("Primary language filter, e.g. 'typescript'. Omit for any.").optional(),
limit: z.number().int().min(1).max(10).default(5).describe("Max results (1-10)"),
page: z.number().int().min(1).default(1).describe("1-based page number"),
},
async ({ query, language, limit, page }) => {
const params = new URLSearchParams({
q: language ? `${query} language:${language}` : query,
sort: "stars", order: "desc",
per_page: String(limit), page: String(page),
});
const res = await fetch(`https://api.github.com/search/repositories?${params}`, { headers: ghHeaders() });
if (res.status === 403) return err("Rate limited. Wait and retry, or set GITHUB_TOKEN.");
if (!res.ok) return err(`API error: ${res.status} ${res.statusText}`);
const data = (await res.json()) as any;
const items = (data.items ?? []).slice(0, limit);
if (!items.length) return { content: [{ type: "text", text: `No results for: ${query}` }] };
const text = items
.map((it: any, i: number) => `${i + 1}. ${it.full_name} ⭐${it.stargazers_count}\n ${it.description ?? ""}\n ${it.html_url}`)
.join("\n\n");
return { content: [{ type: "text", text: `Found ${data.total_count} (page ${page}):\n\n${text}` }] };
},
);
// 工具 3:用户公开资料
server.tool(
"get_user_profile",
"Fetch a GitHub user's public profile (name, bio, followers, public repos).",
{ username: z.string().describe("GitHub username") },
async ({ username }) => {
const res = await fetch(`https://api.github.com/users/${username}`, { headers: ghHeaders() });
if (res.status === 404) return err(`User not found: ${username}`);
if (!res.ok) return err(`API error: ${res.status} ${res.statusText}`);
const u = (await res.json()) as any;
const text = [
`Login: ${u.login}`,
`Name: ${u.name ?? "(none)"}`,
`Bio: ${u.bio ?? "(none)"}`,
`Company: ${u.company ?? "(none)"}`,
`Location: ${u.location ?? "(none)"}`,
`Followers: ${u.followers} Following: ${u.following}`,
`Public repos: ${u.public_repos}`,
`URL: ${u.html_url}`,
].join("\n");
return { content: [{ type: "text", text }] };
},
);
const transport = new StdioServerTransport();
await server.connect(transport);
构建并接入:
npm run build
claude mcp add gh-mcp -- node /绝对路径/gh-mcp/dist/server.js
claude mcp list
之后在 Claude Code 里问「torvalds 这个 GitHub 用户是谁?他有多少粉丝?」模型会自动调用 get_user_profile。
| 现象 | 原因 | 解决 |
|---|---|---|
Cannot find module .../server/mcp.js |
缺 "type": "module" 或 moduleResolution 不对 |
package.json 加 "type":"module",tsconfig 用 bundler |
claude mcp list 里 server 标红 ✗ |
子进程启动失败或 stdout 被污染 | 用 Inspector 单独跑确认能起;排查是否有 console.log |
| 调用工具卡住无响应 | handler 里 throw 或死循环 |
改成返回 isError;加超时 |
| GitHub 频繁返回 403 | 未认证 60 次/小时配额耗尽 | 设 GITHUB_TOKEN 环境变量 |
| zod schema 报类型错误 | 用了 v3 写法 | .describe() 放 .optional() 之前;升级到 v4 写法 |
| Inspector 首次启动很久 | 正在装依赖 | 等 30–60 秒,确保 Node 18+ |
github.com/modelcontextprotocol/typescript-sdk ——含完整 API 与示例 servermodelcontextprotocol.io ——Host/Client/Server 角色与 JSON-RPC 消息流StdioServerTransport 换成 StreamableHTTPServerTransport,可团队共享一个 MCP 服务(本系列「造远程 MCP 服务器并部署上线」已覆盖)server.resource(),团队标准化工作流用 server.prompt()(本系列「知识库 MCP」已覆盖)readOnlyHint/destructiveHint 等四类注解,让模型与权限系统更懂你的工具(本系列「给 MCP 工具挂四类行为注解」已覆盖)claude mcp list 截图证明工具被 Claude Code 成功发现与调用。@modelcontextprotocol/sdk v1.29.0 + Zod v4 从零到 stdio/HTTP 的完整可运行教程,含 InMemoryTransport 测试与 Inspector 调试McpServer/StdioServerTransport/InMemoryTransport 的权威 API 参考本文由「51学通信」原创整理,主理人爱卫生(微信:gprshome201101)。MCP 是 Claude Code 生态的核心扩展机制——一次封装,Claude、Cursor、Windsurf 等所有支持 MCP 的客户端通用。下一篇将进入 Round 4:Claude Code Agent 模式与自动化,讲解
-p无头模式、hooks 与批量任务编排。[!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 等场景全覆盖。
