用 Node.js 开发一个 MCP 服务:从协议本质到最佳实践
MCP(Model Context Protocol)正在成为 AI 应用与外部数据 / 工具之间的"USB-C"。 本文从协议规范出发,带你理解 MCP 的核心架构,再落地到 Node.js / TypeScript 实战,拆解"怎么才算按照 MCP 标准实现了一个 MCP 服务"。
一、MCP 是什么?为什么需要它
1.1 一句话定义
Model Context Protocol(MCP) 是一个开放协议,为 LLM 应用提供了一种标准化的方式来连接外部数据源和工具。
你可以类比 LSP(Language Server Protocol)——LSP 统一了编辑器与编程语言之间的集成方式,而 MCP 统一了 AI 应用与外部能力之间的集成方式。
1.2 解决了什么问题
在 MCP 出现之前,每个 AI 应用(Claude Desktop、Cursor、Windsurf 等)想要接入外部工具或数据源,都需要写一套独立的适配层。这导致了 M × N 的集成问题——M 个宿主应用 × N 个工具/数据源 = M×N 套适配代码。
MCP 将其简化为 M + N:每个宿主只需实现一个 MCP Client,每个工具/数据源只需实现一个 MCP Server,二者通过标准协议通信。
1.3 MCP 的版本
MCP 由 Anthropic 在 2024 年底发起,截至目前(2026 年初),最新规范版本为 2025-11-25。协议仍在快速迭代中,但核心概念已经稳定。
二、协议核心架构
2.1 三层角色
MCP 采用 Host – Client – Server 三层架构:
| 角色 | 职责 | 举例 |
|---|---|---|
| Host | 宿主应用,创建并管理多个 Client,负责安全策略和用户授权 | Cursor、Claude Desktop、VS Code |
| Client | Host 内部与单个 Server 的连接器,负责协议协商和消息路由 | Host 内部实现,对开发者透明 |
| Server | 提供上下文和能力的服务,暴露 Resources / Tools / Prompts | 你要开发的 MCP 服务 |
关键点:每个 Client 与一个 Server 建立一对一的有状态会话,Client 之间彼此隔离。
2.2 通信协议:JSON-RPC 2.0
MCP 的所有消息都基于 JSON-RPC 2.0 编码,UTF-8 格式。消息类型有三种:
- Request(请求):需要对方回复 Response
- Response(响应):对 Request 的回复
- Notification(通知):单向消息,不需要回复
2.3 生命周期:三个阶段
一个 MCP 会话的生命周期由三个阶段组成:
阶段一:初始化(Initialization)
- Client 发送
initialize请求,包含:支持的协议版本、Client 能力、Client 信息 - Server 回复自己的能力和信息
- Client 发送
notifications/initialized通知,标志可以开始正常通信
这一步完成了至关重要的能力协商(Capability Negotiation)——双方明确声明自己支持哪些特性,后续只能使用已协商的能力。
阶段二:运行(Operation)
正常的双向消息交换。Client 可以调用 Tools、读取 Resources、获取 Prompts;Server 也可以向 Client 发送通知。
阶段三:关闭(Shutdown)
通过底层传输机制(关闭 stdin/HTTP 连接等)优雅地终止会话。
2.4 传输层(Transport)
MCP 定义了两种标准传输方式:
stdio(标准输入/输出)
- Client 以子进程的方式启动 Server
- Server 通过
stdin读取消息,通过stdout发送消息 - 消息以换行符分隔
- 最简单、最常用的本地集成方式
致命注意:stdio 模式下,Server 绝对不能向 stdout 写入非 MCP 消息的内容。 也就是说
console.log()会直接破坏 JSON-RPC 消息流!必须使用console.error()或写入文件来做日志。
Streamable HTTP
- Server 作为独立的 HTTP 服务运行,可处理多个 Client 连接
- Client 通过 HTTP POST 发送消息,Server 可选用 SSE(Server-Sent Events)流式返回
- 适合远程部署和多用户场景
| 特性 | stdio | Streamable HTTP |
|---|---|---|
| 部署方式 | 本地子进程 | 独立 HTTP 服务 |
| 多用户 | 否(一对一) | 是 |
| 使用场景 | 本地 IDE / CLI 集成 | 远程服务 / SaaS |
| 复杂度 | 低 | 中 |
三、Server 的三大原语:Tools / Resources / Prompts
这是 MCP Server 最核心的概念。一个 MCP Server 通过这三种原语向外部暴露能力。
3.1 Tools(工具)—— 模型驱动
Tools 是让 LLM "做事"的能力。LLM 可以根据上下文自主发现并调用 Tools。
- 控制方:模型(Model-controlled)
- 类比:函数调用 / API endpoint
- 典型场景:查询天气 API、读写数据库、执行计算、发送消息
一个 Tool 的定义包含:
1{2 name: "get_weather", // 唯一标识3 title: "天气查询", // UI 展示名称(可选)4 description: "获取指定城市的天气", // 给 LLM 看的描述(很重要!)5 inputSchema: { // JSON Schema,定义入参6 type: "object",7 properties: {8 city: { type: "string", description: "城市名称" }9 },10 required: ["city"]11 }12}Tool 的调用结果返回 content 数组,支持文本、图片、音频和嵌入资源等多种格式:
3.2 Resources(资源)—— 应用驱动
Resources 是让 LLM "看到"的数据。由宿主应用决定何时以及如何使用。
- 控制方:应用(Application-controlled)
- 类比:文件系统 / REST 资源
- 典型场景:项目文件内容、数据库 schema、Git 历史、配置信息
每个 Resource 通过 URI 唯一标识,支持列表查询、内容读取和变更订阅:
Resources 还支持 Resource Templates(URI 模板),允许参数化访问:
3.3 Prompts(提示模板)—— 用户驱动
Prompts 是预定义的交互模板。由用户主动选择触发。
- 控制方:用户(User-controlled)
- 类比:斜杠命令(/command)、快捷操作
- 典型场景:
/review代码审查、/explain代码解释、/translate翻译
3.4 三者的对比与选择
| Tools | Resources | Prompts | |
|---|---|---|---|
| 控制方 | LLM 模型 | 宿主应用 | 用户 |
| 用途 | 执行操作 | 提供数据/上下文 | 模板化交互 |
| 发现方式 | LLM 自动发现 | 应用 UI 展示 | 用户命令触发 |
| 有副作用? | 可能有 | 只读 | 无 |
| 何时使用 | 需要"做事"时 | 需要"给上下文"时 | 需要"标准化流程"时 |
实战建议:大多数 MCP Server 从 Tools 起步就够了。Resources 和 Prompts 按需添加。
四、用 Node.js 实现一个 MCP Server
4.1 环境与依赖
@modelcontextprotocol/sdk是官方 TypeScript SDK,zod是其必需的 peer dependency,用于参数 schema 校验。
配置 package.json:
配置 tsconfig.json:
1{2 "compilerOptions": {3 "target": "ES2022",4 "module": "Node16",5 "moduleResolution": "Node16",6 "outDir": "./dist",7 "rootDir": "./src",8 "strict": true,9 "esModuleInterop": true,10 "skipLibCheck": true,11 "forceConsistentCasingInFileNames": true12 },13 "include": ["src/**/*"],14 "exclude": ["node_modules"]15}4.2 最小可运行示例
下面是一个最小的、符合 MCP 标准的 Server 实现:
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";2import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";3import { z } from "zod";4
5// 1. 创建 Server 实例(声明名称和版本)6const server = new McpServer({7 name: "my-mcp-server",8 version: "1.0.0",9});10
11// 2. 注册一个 Tool12server.registerTool(13 "add",14 {15 title: "加法",16 description: "计算两个数字的和",17 inputSchema: {18 a: z.number().describe("第一个数字"),19 b: z.number().describe("第二个数字"),20 },21 },22 async ({ a, b }) => ({23 content: [{ type: "text", text: `${a + b}` }],24 })25);26
27// 3. 连接传输层并启动28const transport = new StdioServerTransport();29await server.connect(transport);30
31// 注意:使用 console.error 而不是 console.log!32console.error("MCP Server is running on stdio");就是这么简单——创建 Server → 注册能力 → 连接传输层,三步走。
SDK 的 McpServer 帮你处理了所有的 JSON-RPC 消息解析、能力协商、生命周期管理,你只需要专注于业务逻辑。
4.3 注册 Tools
server.registerTool() 方法的签名:
一个更完整的示例——查询 GitHub 用户信息:
1server.registerTool(2 "get_github_user",3 {4 title: "GitHub 用户信息",5 description: "根据用户名查询 GitHub 用户的公开信息",6 inputSchema: {7 username: z.string().min(1).describe("GitHub 用户名"),8 },9 },10 async ({ username }) => {11 try {12 const response = await fetch(13 `https://api.github.com/users/${username}`,14 {15 headers: { "User-Agent": "my-mcp-server/1.0.0" },16 }17 );18
19 if (!response.ok) {20 return {21 content: [22 {23 type: "text",24 text: `查询失败:${response.status} ${response.statusText}`,25 },26 ],27 isError: true,28 };29 }30
31 const user = await response.json();32 return {33 content: [34 {35 type: "text",36 text: [37 `用户名: ${user.login}`,38 `昵称: ${user.name || "未设置"}`,39 `Bio: ${user.bio || "无"}`,40 `公开仓库数: ${user.public_repos}`,41 `关注者: ${user.followers}`,42 `主页: ${user.html_url}`,43 ].join("\n"),44 },45 ],46 };47 } catch (error) {48 return {49 content: [50 { type: "text", text: `请求异常:${(error as Error).message}` },51 ],52 isError: true,53 };54 }55 }56);4.4 注册 Resources
1import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";2
3// 静态资源4server.registerResource(5 "project-readme", // 资源标识6 "file:///project/README.md", // URI7 {8 title: "Project README",9 description: "项目说明文件",10 mimeType: "text/markdown",11 },12 async (uri) => ({13 contents: [14 {15 uri: uri.href,16 mimeType: "text/markdown",17 text: "# My Project\n\nThis is the README.",18 },19 ],20 })21);22
23// 动态资源模板24const userProfileTemplate = new ResourceTemplate("users://{userId}/profile");25
26server.registerResource(27 "user-profile",28 userProfileTemplate,29 {30 title: "用户资料",31 description: "用户资料 JSON",32 mimeType: "application/json",33 },34 async (uri, { userId }) => {35 const profile = await fetchUserProfile(userId);36 return {37 contents: [38 {39 uri: uri.href,40 mimeType: "application/json",41 text: JSON.stringify(profile),42 },43 ],44 };45 }46);4.5 注册 Prompts
1server.registerPrompt(2 "code_review",3 {4 title: "代码审查",5 description: "审查代码并给出改进建议",6 argsSchema: {7 code: z.string().describe("待审查的代码"),8 language: z.string().optional().describe("编程语言"),9 },10 },11 async ({ code, language }) => ({12 messages: [13 {14 role: "user",15 content: {16 type: "text",17 text: [18 `请审查以下${language ? language + " " : ""}代码:`,19 "",20 "```" + (language || ""),21 code,22 "```",23 "",24 "请从以下几个方面给出建议:",25 "1. 代码质量和可读性",26 "2. 潜在的 bug 或边界情况",27 "3. 性能优化空间",28 "4. 最佳实践合规性",29 ].join("\n"),30 },31 },32 ],33 })34);五、怎么才算"符合 MCP 标准"
这一节回答一个关键问题:你的 Server 需要满足哪些条件,才算是按 MCP 协议实现的?
5.1 必须实现的(MUST)
- JSON-RPC 2.0 消息格式:所有通信必须遵循 JSON-RPC 2.0 规范
- 生命周期管理:正确处理
initialize→initialized→ 正常运作 → 关闭 的完整流程 - 能力声明:在初始化时,通过
capabilities正确声明 Server 支持的功能 - 协议版本协商:在
initialize阶段与 Client 协商一致的协议版本 - 只使用已协商的能力:不能使用未在初始化时声明的功能
好消息:如果你用
@modelcontextprotocol/sdk,以上这些全部由 SDK 自动处理。你注册了什么能力,SDK 就自动声明什么 capability。
5.2 应该遵守的(SHOULD)
- 输入校验:对 Tool 的所有入参进行校验(Zod 天然解决)
- 错误处理:区分协议错误(JSON-RPC error)和业务错误(
isError: true的 Tool 结果) - 超时处理:为所有请求设置合理的超时
- 日志规范:stdio 模式下日志写 stderr,不写 stdout
- Tool 描述要清晰:
description是 LLM 理解工具的唯一依据,写得越清晰,调用越准确
5.3 可选实现的
- Resources 订阅:支持客户端订阅资源变更通知
- 列表变更通知:当 Tools/Resources/Prompts 列表变化时发送
listChanged通知 - 进度追踪:长时间操作时发送进度通知
- 取消支持:响应客户端的取消请求
- 日志输出:通过 MCP 日志协议向客户端发送结构化日志
5.4 一张自检清单
六、完整实战:GitHub MCP Server
下面是一个完整的、生产级的 MCP Server 示例,提供 GitHub 相关的查询能力:
1#!/usr/bin/env node2
3import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";4import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";5import { z } from "zod";6
7const GITHUB_API = "https://api.github.com";8const USER_AGENT = "github-mcp-server/1.0.0";9
10// ---------- 辅助函数 ----------11
12async function githubRequest<T>(path: string): Promise<T> {13 const response = await fetch(`${GITHUB_API}${path}`, {14 headers: {15 "User-Agent": USER_AGENT,16 Accept: "application/vnd.github.v3+json",17 // 如果有 token:18 // "Authorization": `Bearer ${process.env.GITHUB_TOKEN}`,19 },20 });21
22 if (!response.ok) {23 throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);24 }25
26 return response.json() as Promise<T>;27}28
29function formatError(error: unknown): string {30 return error instanceof Error ? error.message : String(error);31}32
33// ---------- 创建 Server ----------34
35const server = new McpServer({36 name: "github-mcp-server",37 version: "1.0.0",38});39
40// ---------- Tools ----------41
42// 查询用户信息43server.registerTool(44 "get_user",45 {46 title: "GitHub 用户信息",47 description: "查询 GitHub 用户的公开信息",48 inputSchema: {49 username: z.string().min(1).describe("GitHub 用户名"),50 },51 },52 async ({ username }) => {53 try {54 const user = await githubRequest<Record<string, unknown>>(55 `/users/${username}`56 );57 return {58 content: [59 {60 type: "text" as const,61 text: JSON.stringify(user, null, 2),62 },63 ],64 };65 } catch (error) {66 return {67 content: [{ type: "text" as const, text: formatError(error) }],68 isError: true,69 };70 }71 }72);73
74// 搜索仓库75server.registerTool(76 "search_repos",77 {78 title: "GitHub 仓库搜索",79 description: "根据关键词搜索 GitHub 仓库",80 inputSchema: {81 query: z.string().min(1).describe("搜索关键词"),82 sort: z83 .enum(["stars", "forks", "updated"])84 .optional()85 .describe("排序方式"),86 limit: z87 .number()88 .min(1)89 .max(30)90 .optional()91 .default(5)92 .describe("返回数量,默认 5"),93 },94 },95 async ({ query, sort, limit }) => {96 try {97 const params = new URLSearchParams({98 q: query,99 per_page: String(limit),100 });101 if (sort) params.set("sort", sort);102
103 const result = await githubRequest<{104 total_count: number;105 items: Array<{106 full_name: string;107 description: string | null;108 stargazers_count: number;109 language: string | null;110 html_url: string;111 }>;112 }>(`/search/repositories?${params}`);113
114 const formatted = result.items115 .map(116 (repo, i) =>117 `${i + 1}. **${repo.full_name}** ⭐${repo.stargazers_count}\n` +118 ` ${repo.description || "无描述"}\n` +119 ` 语言: ${repo.language || "未知"} | ${repo.html_url}`120 )121 .join("\n\n");122
123 return {124 content: [125 {126 type: "text" as const,127 text: `共找到 ${result.total_count} 个仓库,展示前 ${limit} 个:\n\n${formatted}`,128 },129 ],130 };131 } catch (error) {132 return {133 content: [{ type: "text" as const, text: formatError(error) }],134 isError: true,135 };136 }137 }138);139
140// 获取仓库的 README141server.registerTool(142 "get_repo_readme",143 {144 title: "仓库 README",145 description: "获取指定 GitHub 仓库的 README 内容",146 inputSchema: {147 owner: z.string().describe("仓库所有者"),148 repo: z.string().describe("仓库名称"),149 },150 },151 async ({ owner, repo }) => {152 try {153 const response = await fetch(154 `${GITHUB_API}/repos/${owner}/${repo}/readme`,155 {156 headers: {157 "User-Agent": USER_AGENT,158 Accept: "application/vnd.github.raw+json",159 },160 }161 );162
163 if (!response.ok) {164 throw new Error(`${response.status} ${response.statusText}`);165 }166
167 const readme = await response.text();168 return {169 content: [{ type: "text" as const, text: readme }],170 };171 } catch (error) {172 return {173 content: [{ type: "text" as const, text: formatError(error) }],174 isError: true,175 };176 }177 }178);179
180// ---------- Resources ----------181
182server.registerResource(183 "github-rate-limit",184 "github://rate-limit",185 {186 title: "GitHub Rate Limit",187 description: "GitHub API 速率限制信息",188 mimeType: "application/json",189 },190 async (uri) => {191 const data = await githubRequest<Record<string, unknown>>("/rate_limit");192 return {193 contents: [194 {195 uri: uri.href,196 mimeType: "application/json",197 text: JSON.stringify(data, null, 2),198 },199 ],200 };201 }202);203
204// ---------- Prompts ----------205
206server.registerPrompt(207 "analyze_repo",208 {209 title: "仓库分析",210 description: "分析一个 GitHub 仓库的技术栈和架构",211 argsSchema: {212 owner: z.string().describe("仓库所有者"),213 repo: z.string().describe("仓库名称"),214 },215 },216 async ({ owner, repo }) => ({217 messages: [218 {219 role: "user" as const,220 content: {221 type: "text" as const,222 text: [223 `请帮我分析 GitHub 仓库 ${owner}/${repo},包括:`,224 "",225 "1. 项目概述和用途",226 "2. 技术栈分析(语言、框架、工具链)",227 "3. 项目架构和代码组织方式",228 "4. 代码质量评估",229 "5. 值得学习的设计模式或最佳实践",230 "",231 "请先使用 get_user 和 search_repos 等工具获取相关信息。",232 ].join("\n"),233 },234 },235 ],236 })237);238
239// ---------- 启动 ----------240
241async function main() {242 const transport = new StdioServerTransport();243 await server.connect(transport);244 console.error("GitHub MCP Server running on stdio");245}246
247main().catch((error) => {248 console.error("Fatal error:", error);249 process.exit(1);250});七、开发实践与避坑指南
7.1 Tool 的 description 是灵魂
LLM 决定是否调用你的 Tool,唯一的依据就是 description。写好描述比写好代码更重要。
1// ❌ 差的描述2server.registerTool("query", { description: "查询数据" }, ...);3
4// ✅ 好的描述5server.registerTool(6 "search_products",7 {8 description: "根据关键词搜索商品列表,返回商品名称、价格和库存信息。支持按价格排序。",9 inputSchema: { ... },10 },11 ...12);对于参数的 describe() 也同样重要——它帮助 LLM 理解应该传入什么值。
7.2 错误处理的两个层次
MCP 区分两种错误,处理方式不同:
业务错误应该包含对 LLM 有用的信息(比如"应该传什么格式"),这样 LLM 就能自动重试。
7.3 stdio 下的日志陷阱
这是新手最容易踩的坑:
7.4 Tool 命名规范
根据 MCP 规范,Tool 名称应遵循:
- 长度 1-128 字符
- 只允许:字母(A-Z, a-z)、数字(0-9)、下划线(_)、连字符(-)、点(.)
- 不要包含空格或其他特殊字符
- 在同一个 Server 内唯一
- 区分大小写
7.5 在 Cursor 中测试
开发完成后,在 Cursor 的 MCP 配置中添加你的 Server:
重启 Cursor 后,你的 Tools 就会出现在可用工具列表中。
如果你使用 tsx 进行开发调试,也可以直接配置:
7.6 使用 MCP Inspector 调试
官方提供了 MCP Inspector 工具,可以在浏览器中交互式测试你的 Server:
这会启动一个 Web UI,让你可以:
- 查看 Server 声明的所有 capabilities
- 列出并手动调用 Tools
- 浏览 Resources
- 测试 Prompts
(↓ 如图所示 ↓)
八、进阶:Streamable HTTP 传输
如果你需要将 MCP Server 部署为远程服务,可以使用 Streamable HTTP 传输:
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";2import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";3import express from "express";4
5const app = express();6const server = new McpServer({7 name: "my-remote-server",8 version: "1.0.0",9});10
11// 注册 Tools / Resources / Prompts(同上)...12
13// 设置 HTTP transport14app.post("/mcp", async (req, res) => {15 const transport = new StreamableHTTPServerTransport("/mcp");16 await server.connect(transport);17 await transport.handleRequest(req, res);18});19
20app.listen(3000, () => {21 console.log("MCP Server listening on http://localhost:3000/mcp");22});注意:Streamable HTTP 模式需要额外考虑 Origin 校验(防止 DNS 重绑定攻击)、Session 管理 和 认证授权。
九、总结
MCP Server 开发的核心要素
- 协议层:基于 JSON-RPC 2.0,遵循 initialize → operation → shutdown 生命周期
- 能力层:通过 Tools / Resources / Prompts 三大原语暴露能力,在初始化时声明 capabilities
- 传输层:stdio(本地)或 Streamable HTTP(远程),选择合适的传输方式
- 实现层:使用
@modelcontextprotocol/sdk+zod,聚焦业务逻辑
"做对了"的标志
- ✅ 能被 MCP Inspector 正确识别和调用
- ✅ 在 Cursor / Claude Desktop 等宿主中正常工作
- ✅ Tool 描述清晰,LLM 能准确理解何时调用
- ✅ 错误处理完善,业务错误能让 LLM 自我修正
- ✅ stdio 模式下没有污染 stdout
推荐资源
- MCP 官方规范:modelcontextprotocol.io/specification
- TypeScript SDK:@modelcontextprotocol/sdk
- 官方教程:Build an MCP Server
- MCP Inspector:
npx @modelcontextprotocol/inspector
MCP 的生态还在快速发展中。当你的 AI 工作流需要接入外部能力时,MCP 就是那个"标准答案"。现在开始动手吧。