用TypeScript构建自己的MCP服务器 — @modelcontextprotocol/sdk实战教程
用@modelcontextprotocol/sdk v1.29.0和Zod v4从零构建TypeScript MCP服务器的实战教程。逐步讲解工具注册、InMemoryTransport测试、公共API集成,30分钟内完成一个可运行的服务器。
// 就这一行代码
const server = new McpServer({ name: "my-server", version: "1.0.0" });
server.tool("get_book_info", "查询图书信息", { isbn: z.string() }, async ({ isbn }) => { ... });
第一次决定尝试构建MCP服务器时,我说实话,惊讶于它比想象中容易得多。虽然经常听说MCP(Model Context Protocol)正在成为AI平台之间的标准,但”自己构建服务器”这个说法总让人觉得复杂、门槛很高。然而,只需安装一个@modelcontextprotocol/sdk包和Zod,就能在30分钟内完成一个可运行的MCP服务器。
于是我自己动手做了一个。下面出现的全是我亲手跑过的代码,以及当时返回的真实输出。我在公共API(Open Library)上搭了一个用ISBN查图书信息的工具,目的就是靠双手把MCP服务器到底怎么运转这件事弄明白,而不是停留在文档层面。
为什么现在要亲手构建MCP服务器
Claude、Cursor、Windsurf、Zed等主流AI编码工具都已将MCP作为标准集成协议采用。这意味着构建一个MCP服务器,就可以不依赖特定AI平台,在多个AI工具中使用相同的功能。
过去如果要开发自己的API,还需要为每个AI平台单独开发插件或集成,而MCP让”构建一次,处处可用”成为可能。我关注这一点的理由很简单。将公司内部数据库查询、内部文档搜索、特定业务自动化脚本用MCP工具包装一次,就可以在Claude和Cursor中同样使用。
当然,生态系统还没有完全成熟。但正如在MCP开放标准与Linux Foundation参与中所确认的,这个方向已经在成为行业标准。如果你对协议本身的工作原理感兴趣,建议先读一遍官方规范页面。其中整理了主机、客户端、服务器的角色划分,以及它们之间的 JSON-RPC 2.0 消息流。
另外,这篇文章讲的是 TypeScript SDK,但 Python 阵营也遵循类似的模式。如果你想用 Python 做同样的事情,用 FastMCP 构建 Python MCP 服务器这篇文章会有帮助。对比两种语言的 SDK 设计,能更清楚地看到 MCP 协议的共通结构。
环境配置与包安装
首先初始化Node.js项目并安装所需包。
mkdir my-mcp-server && cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
实际运行结果如下:
$ mkdir test-project && cd test-project && npm init -y
$ npm install @modelcontextprotocol/sdk zod
added 92 packages, and audited 93 packages in 2s
found 0 vulnerabilities
安装了@modelcontextprotocol/sdk 1.29.0版本和zod 4.4.3版本。依赖项增加到92个包,但没有漏洞。如果使用TypeScript,还需要添加开发依赖:
npm install -D typescript @types/node tsx
npx tsc --init
在tsconfig.json中设置"module": "ESNext"和"moduleResolution": "bundler"(或"node16")非常重要。这是因为SDK以ESM模块方式分发。
package.json配置
{
"name": "my-mcp-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
},
"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的导入路径(@modelcontextprotocol/sdk/server/mcp.js)就无法正确解析。
创建McpServer实例: 核心三步模式
通过实际实验确认的核心是三个步骤。
- 创建
McpServer实例 - 用
server.tool()注册基于Zod schema的工具 - 连接传输层
理解了这个模式,就掌握了MCP服务器开发的90%。
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { z } from "zod";
// 第一步:创建服务器实例
const server = new McpServer({ name: "demo-server", version: "1.0.0" });
McpServer构造函数接收name和version两个字段。这些信息在MCP客户端识别服务器时使用。
注册工具:server.tool()
// 第二步:注册工具
server.tool(
"get_book_info", // 工具名称
"Fetch book metadata from Open Library by ISBN", // 工具描述(AI判断何时使用的依据)
{ isbn: z.string().describe("ISBN-13 or ISBN-10") }, // Zod schema
async ({ isbn }) => { // 执行处理器
const url = `https://openlibrary.org/api/books?bibkeys=ISBN:${isbn}&format=json&jscmd=data`;
const res = await fetch(url);
const data = await res.json();
const book = data[`ISBN:${isbn}`];
if (!book) {
return {
content: [{ type: "text", text: `No book found for ISBN: ${isbn}` }]
};
}
const summary = [
`Title: ${book.title}`,
`Author(s): ${(book.authors || []).map((a: { name: string }) => a.name).join(", ")}`,
`Published: ${book.publish_date || "unknown"}`,
`Pages: ${book.number_of_pages || "unknown"}`
].join("\n");
return { content: [{ type: "text", text: summary }] };
}
);
清楚理解server.tool()的参数结构非常重要:
- 第一个参数:工具名称(AI调用时使用的标识符)
- 第二个参数:工具描述(AI模型判断何时应使用此工具的自然语言描述)
- 第三个参数:Zod schema对象(输入值类型和描述)
- 第四个参数:异步处理器函数
第二个参数”工具描述”是AI模型读取的提示词。描述越具体,AI在合适的情况下使用工具就越准确。“get info”这样模糊的描述远不如”Fetch book metadata from Open Library by ISBN”好。
响应格式:MCP标准结构
处理器返回的格式也有标准:
return {
content: [
{ type: "text", text: "响应文本" }
]
};
content数组中的每个项目包含type和该类型对应的数据。除文本外,也可以返回图片(type: "image")或资源(type: "resource"),但大多数情况下文本就足够了。
即使发生错误,MCP惯例也是不抛出异常,而是将错误信息包装在content中返回。抛出异常会导致不同客户端的处理方式不同。
使用InMemoryTransport在同一进程中测试
在将MCP服务器连接到实际的Claude或Cursor之前,有一种方法可以在同一进程内测试服务器-客户端往返通信。只需使用InMemoryTransport。
// 第三步:连接传输层
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
// 连接服务器
await server.connect(serverTransport);
// 连接客户端
const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(clientTransport);
InMemoryTransport.createLinkedPair()返回一对互相连接的客户端传输层和服务器传输层。无需配置stdio或HTTP服务器,直接在内存中通信。
现在用客户端查询并调用服务器的工具列表:
// 查询已注册的工具列表
const { tools } = await client.listTools();
console.log("=== Tools registered ===");
tools.forEach(t => console.log(` - ${t.name}: ${t.description}`));
// 调用工具
const result = await client.callTool({
name: "get_book_info",
arguments: { isbn: "9780132350884" }
});
console.log("\n=== Result ===");
console.log(result.content[0].text);
await client.close();
实际运行结果:
=== Tools registered ===
- get_book_info: Fetch book metadata from Open Library by ISBN
=== Calling get_book_info (ISBN: 9780132350884 — Clean Code) ===
Title: Clean Code
Author(s): Robert C. Martin
Published: July 2008
Pages: 431
✓ MCP server + client round-trip succeeded
从Open Library的公共API成功获取了真实数据并正常输出。由于使用的是无需API密钥的公共REST API,不需要额外的认证配置。
完整可运行代码(mcp-demo.mjs)
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { z } from "zod";
const server = new McpServer({ name: "demo-server", version: "1.0.0" });
server.tool(
"get_book_info",
"Fetch book metadata from Open Library by ISBN",
{ isbn: z.string().describe("ISBN-13 or ISBN-10") },
async ({ isbn }) => {
const url = `https://openlibrary.org/api/books?bibkeys=ISBN:${isbn}&format=json&jscmd=data`;
const res = await fetch(url);
const data = await res.json();
const book = data[`ISBN:${isbn}`];
if (!book) {
return { content: [{ type: "text", text: `No book found for ISBN: ${isbn}` }] };
}
const summary = `Title: ${book.title}\nAuthor(s): ${(book.authors || []).map(a => a.name).join(", ")}\nPages: ${book.number_of_pages || "unknown"}`;
return { content: [{ type: "text", text: summary }] };
}
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(clientTransport);
const { tools } = await client.listTools();
console.log("Tools:", tools.map(t => t.name));
const result = await client.callTool({ name: "get_book_info", arguments: { isbn: "9780132350884" } });
console.log(result.content[0].text);
await client.close();
使用StdioServerTransport与Claude实际集成
InMemoryTransport非常适合测试和开发调试,但要连接到实际的Claude Desktop或Cursor,需要切换到StdioServerTransport。这是MCP服务器的标准部署方式。
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: "book-server", version: "1.0.0" });
server.tool(
"get_book_info",
"Fetch book metadata from Open Library by ISBN",
{ isbn: z.string().describe("ISBN-13 or ISBN-10") },
async ({ isbn }) => {
const url = `https://openlibrary.org/api/books?bibkeys=ISBN:${isbn}&format=json&jscmd=data`;
const res = await fetch(url);
const data = await res.json();
const book = data[`ISBN:${isbn}`];
if (!book) {
return { content: [{ type: "text", text: `No book found for ISBN: ${isbn}` }] };
}
const summary = `Title: ${book.title}\nAuthor(s): ${(book.authors || []).map((a: { name: string }) => a.name).join(", ")}\nPublished: ${book.publish_date || "unknown"}\nPages: ${book.number_of_pages || "unknown"}`;
return { content: [{ type: "text", text: summary }] };
}
);
// 以stdio传输层启动
const transport = new StdioServerTransport();
await server.connect(transport);
现在在Claude Desktop的MCP配置文件(~/Library/Application Support/Claude/claude_desktop_config.json)中注册服务器:
{
"mcpServers": {
"book-server": {
"command": "node",
"args": ["/绝对路径/my-mcp-server/dist/server.js"]
}
}
}
如果用TypeScript编写,构建后指向dist/server.js,或者使用tsx:
{
"mcpServers": {
"book-server": {
"command": "npx",
"args": ["tsx", "/绝对路径/my-mcp-server/src/server.ts"]
}
}
}
重启Claude Desktop后,在对话中提到ISBN时,会自动使用get_book_info工具。
注册多个工具
多次调用server.tool()可以添加更多工具:
// 添加图书搜索工具
server.tool(
"search_books",
"Search books by title or author on Open Library",
{
query: z.string().describe("Book title or author name"),
limit: z.number().int().min(1).max(10).default(5).describe("Max results"),
},
async ({ query, limit }) => {
const url = `https://openlibrary.org/search.json?q=${encodeURIComponent(query)}&limit=${limit}`;
const res = await fetch(url);
const data = await res.json();
if (!data.docs || data.docs.length === 0) {
return { content: [{ type: "text", text: `No results for: ${query}` }] };
}
const results = data.docs
.slice(0, limit)
.map((doc: { title: string; author_name?: string[]; first_publish_year?: number }, i: number) =>
`${i + 1}. ${doc.title} — ${(doc.author_name || ["unknown"]).join(", ")} (${doc.first_publish_year || "?"})`
)
.join("\n");
return { content: [{ type: "text", text: results }] };
}
);
在Zod schema中使用.default(5)可以使参数变为可选,当AI没有明确提供值时使用默认值。
设计工具时需要注意的问题
实际使用后,发现了几个陷阱。
第一,需要注意工具名称和描述。由于AI模型选择工具的依据是工具描述,如果描述模糊(如”get info”),AI可能不会在合适的情况下使用工具,或者使用错误。在描述中包含具体的动作、输入和输出非常重要。
第二,Zod v4的API与v3不同。在Zod 4.4.3中,部分API发生了变化。直接复制粘贴原有的Zod v3代码可能会出现类型错误。特别是.optional()和.nullable()的组合方式,以及z.union()的行为方式都有所改变。如果现有项目中使用了Zod,需要检查是否存在版本冲突。
第三,错误处理需要谨慎。我早期犯的错误是在网络错误时直接抛出异常。在MCP标准中,将工具执行错误也包装在content中返回更为安全。因为不同客户端处理异常的方式不同:
async ({ isbn }) => {
try {
const res = await fetch(url);
if (!res.ok) {
return {
content: [{ type: "text", text: `API error: ${res.status} ${res.statusText}` }],
isError: true // 明确标记为错误
};
}
// ... 正常处理
} catch (error) {
return {
content: [{ type: "text", text: `Network error: ${error instanceof Error ? error.message : String(error)}` }],
isError: true
};
}
}
添加isError: true标志后,客户端可以将响应识别为错误。
第四,需要截断过长的响应。AI的上下文窗口是有限的。工具响应过长会导致AI丢失之前的对话,或出现上下文超出错误。对搜索结果或列表类数据,限制最大条目数是明智之举。
使用MCP Inspector进行调试
开发过程中,我大量使用了MCP Inspector。这是一个基于浏览器的GUI,可以查看MCP服务器的工具列表并直接调用。
npx @modelcontextprotocol/inspector node dist/server.js
在浏览器中打开http://localhost:5173,可以可视化地查看服务器注册的工具列表和每个工具的Zod schema。输入参数并执行后,实际响应会以JSON格式输出。
需要注意的是,MCP Inspector需要单独的Node.js 18+环境。而且Inspector本身也需要安装npm包,首次运行可能需要数十秒。
资源与提示词:除工具外的MCP功能
MCP规范除了工具(Tools)外,还提供资源(Resources)和提示词(Prompts)两种功能。
资源是AI可读取的数据源。将文件、数据库表、API响应等作为资源暴露,AI就可以将其作为上下文读取:
server.resource(
"book-catalog",
"books://catalog",
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: "text/plain",
text: "图书目录内容..."
}
]
})
);
提示词是可复用的提示词模板。可以为复杂任务定义多步骤提示词,让客户端轻松调用。
说实话,最初我不太清楚资源和提示词功能在实际中用得有多多。在大多数实际使用场景中,仅靠工具就已足够。资源在提供大量上下文数据时有用,提示词在标准化工作流程时有用。
用Zod v4处理复杂Schema
除了简单的z.string()外,还可以将各种Zod schema用作工具参数:
server.tool(
"advanced_book_search",
"Advanced book search with filters",
{
query: z.string().min(1).max(200).describe("Search query"),
filters: z.object({
publishedAfter: z.number().int().min(1000).max(2030).optional()
.describe("Filter by publication year (start)"),
language: z.enum(["en", "ko", "ja", "zh"]).optional()
.describe("Language filter"),
}).optional().describe("Optional search filters"),
sortBy: z.enum(["relevance", "date", "title"]).default("relevance")
.describe("Sort order"),
limit: z.number().int().min(1).max(20).default(5),
},
async ({ query, filters, sortBy, limit }) => {
// 实现...
return { content: [{ type: "text", text: "结果..." }] };
}
);
组合使用z.object()、z.enum()、.optional()、.default()等,可以以类型安全的方式定义复杂的工具接口。AI会将这个schema转换为JSON Schema格式,从而理解应该如何填写参数。
注意:在Zod v4中,.describe()应在.optional()之前添加。z.string().optional().describe()可能会导致类型推断不符合预期。正确顺序是z.string().describe("描述").optional()。
HTTP/SSE传输层:部署为远程MCP服务器
到目前为止都是本地stdio方式。如果需要运营全团队共享的MCP服务器,或部署到云端,就需要HTTP/SSE传输层。
npm install express
npm install -D @types/express
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { z } from "zod";
const app = express();
app.use(express.json());
const server = new McpServer({ name: "remote-book-server", version: "1.0.0" });
server.tool(
"get_book_info",
"Fetch book metadata from Open Library by ISBN",
{ isbn: z.string() },
async ({ isbn }) => {
// ... 相同的实现
}
);
const transports: Record<string, SSEServerTransport> = {};
app.get("/sse", (req, res) => {
const sessionId = crypto.randomUUID();
const transport = new SSEServerTransport(`/messages/${sessionId}`, res);
transports[sessionId] = transport;
server.connect(transport);
req.on("close", () => {
delete transports[sessionId];
});
});
app.post("/messages/:sessionId", async (req, res) => {
const transport = transports[req.params.sessionId];
if (!transport) {
res.status(404).json({ error: "Session not found" });
return;
}
await transport.handlePostMessage(req, res);
});
app.listen(3000, () => {
console.log("MCP server running on http://localhost:3000");
});
以这种方式部署后,Cursor或Claude Desktop可以将http://localhost:3000/sse注册为MCP服务器URL。不过HTTP方式配置更复杂,安全(认证、HTTPS)处理也需要单独进行。如果只是团队内部工具,stdio要简单得多。
正如在MCP vs A2A vs Open Responses协议比较中讨论的,远程MCP服务器架构在成熟度和安全方面还有很多需要整理的地方。如果现在计划远程部署,认证令牌、CORS和会话管理是必须认真考虑的问题。
实际可用的工具创意
读到这里,应该已经充分理解了MCP服务器的结构。以下是我考虑过的一些实用工具创意:
内部系统集成:
- Jira/Linear问题查询(
get_issue、create_issue、list_my_issues) - Confluence/Notion文档搜索(
search_docs、get_page) - Slack消息搜索(
search_messages、get_channel_history)
开发工作流:
- GitHub PR列表和评审(
list_prs、get_pr_diff、add_review_comment) - 部署状态检查(
get_deployment_status、rollback_deployment) - 日志查询(
search_logs、get_error_trace)
数据分析:
- SQL查询执行(只读权限)(
run_query、list_tables) - 指标仪表板数据查询(
get_metrics、get_alert_status)
以上任何一个,用server.tool()模式包装一下,30分钟内就能变成MCP服务器。
何时该用 MCP 服务器,何时该避开
亲手做过之后,我也明确意识到 MCP 服务器并非万能。这里整理一下选择标准。
适合用 MCP 服务器的情况:
- 当你想让同一个功能同时在多个 AI 客户端中使用时,比如 Claude、Cursor、Windsurf 等。包装一次,就不必为每个客户端单独做集成。
- 当你要暴露一个需要 AI 自主调用的工具时,例如查询内部数据库、检索文档、查看部署状态。
- 输入 schema 明确、结果可以用文本概括的任务。像 ISBN 查询这样输入输出形态清晰的场景最为理想。
最好避开 MCP 服务器的情况:
- 如果功能只在单个应用内部使用,就没必要用 MCP 包装。直接调用函数更快,也更容易调试。
- 需要实时流式传输或大体积二进制传输的任务。MCP 是基于 JSON-RPC 的请求-响应模型,并不适合这类模式。
- 响应超过几十 KB 的大体积数据。它会占用 AI 上下文窗口,反而适得其反。这种情况应当把数据作为资源而非工具暴露,或先设计好结果的分页。
- 在安全边界严格的生产环境中,却无力自行实现认证与权限管理时。正如官方规范的 Security 部分所强调,远程 HTTP 部署需要单独设计用户同意与访问控制。
判断犹豫不定时,先问一句:AI 是否需要自己选择并调用那个工具。如果答案是”是”,那么 MCP 服务器合适;如果是”我从代码里直接调用就行”,那普通函数或库更好。在本地私有运行的具体案例,我在构建本地 LLM 与私有 MCP 服务器这篇文章里有更多展开。
总结:我认为这将成为AI工具部署的现实标准
通过这次实验,我确认了MCP服务器开发的门槛比想象的低得多。@modelcontextprotocol/sdk的McpServer API清晰明了,用Zod定义类型安全的schema也很直观。
但局限也很明显。要与实际的Claude或Cursor客户端集成,需要切换到StdioServerTransport,这个过程中还涉及部署环境、绝对路径配置、Node.js版本兼容性等额外设置。另外,Zod v4相比v3部分API有所变化,现有代码不能直接使用的情况也存在。
尽管如此,能够在无需API密钥的情况下,在30分钟内完成一个从公共REST API包装到实际数据查询的端到端可运行管道,这显然是有吸引力的。在Claude、Cursor、Windsurf等已将MCP作为标准采用的情况下,MCP服务器是将自己的工具同时暴露给多个AI平台的最现实方式。
下一步,建议选择一个实际的内部系统,尝试将其包装为MCP工具。代码结构就是本文所涉及的全部内容。剩下的,就是理解那个系统的API了。
参考链接:
常见问题
如何用TypeScript构建MCP服务器?
@modelcontextprotocol/sdk如何开始使用?
stdio传输与HTTP/SSE传输有什么区别?
MCP工具中的错误该如何处理?
阅读其他语言版本
- 🇰🇷 한국어
- 🇯🇵 日本語
- 🇺🇸 English
- 🇨🇳 中文(当前页面)
这篇文章有帮助吗?
您的支持能帮助我创作更好的内容。请我喝杯咖啡吧。