MCP是什么
Model Context Protocol简称MCP 它是Anthropic 2024 年开源的协议。 让 LLM 接工具/资源/提示词的方式标准化。
解决了什么问题
在没有MCP之前,每个AI应用都需要自己写胶水代码。 每换一个模型/工具就需要重写一遍。 而MCP就可以解决这个问题。
架构
1
2┌──────────────┐ MCP 协议 ┌──────────────┐
3
4│ MCP Client │ ────────> │ MCP Server │
5
6│ (AI 应用) │ <──────── │ (工具提供方) │
7
8└──────────────┘ Transport └──────────────┘- Server:暴露 tools / resources / prompts
- Client:发现并调用
- Transport:传输层(stdio / HTTP / InMemory)
MCP的三大能力
| 能力 | 用途 | 类比 |
|---|---|---|
| Tools | 让 AI 执行动作 | POST 请求 |
| Resources | 让 AI 读数据 | GET 请求 |
| Prompts | 复用 prompt 模板 | 函数库 |
Tools
下面是只用到了Tools,只能让AI执行动作
Server端注册工具
1
2// 引入 MCP Server 类,是工具/资源/提示词的容器
3import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
5// zod 用来定义工具参数 schema,自动校验 + 生成 JSON Schema
6import { z } from "zod";
7
8// 创建 Server 实例,name 和 version 是 MCP 协议要求的元数据
9// Client 握手时会读到,方便做版本兼容判断
10const mcpServer = new McpServer({
11 name: "my-tools",
12 version: "1.0.0",
13});
14
15// 注册一个名为 getWeather 的工具
16mcpServer.tool(
17 "getWeather", // 工具名,LLM 通过这个名字调用
18 "获取指定城市的当前天气温度", // 描述,给 LLM 看,决定何时调用
19 { city: z.string().describe("城市名称") }, // 参数 schema:必须传 city,类型 string
20 async ({ city }) => { // 真正的执行函数,参数已被 zod 校验
21 // 返回值结构固定:content 数组里每项是 type+text
22 // LLM 应用层会把这些 text 拼起来作为「工具结果」
23 return { content: [{ type: "text", text: `${city} 23°C` }] };
24 }
25);
26Client 端调用
1// Client 类:用来和 Server 通信
2import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
4// InMemoryTransport:同进程内存通道,最适合学习和调试
5import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
6
7// 创建 Client,name/version 同样是元数据
8const client = new Client({ name: "my-app", version: "1.0.0" });
9
10// 创建一对相连的 transport:c 给 Client,s 给 Server
11// 像两端拉好的一根管道,写一端另一端能读到
12const [c, s] = InMemoryTransport.createLinkedPair();
13// 同时启动两端连接(必须并行,否则握手会卡死)
14
15await Promise.all([client.connect(c), mcpServer.connect(s)]);
16
17// 1. 发现工具:Client 问 Server「你有哪些工具?」
18// 返回 [{ name, description, inputSchema }, ...]
19const { tools } = await client.listTools();
20
21// 2. 调用工具:指定工具名 + 参数,结构和 listTools 看到的对得上
22const result = await client.callTool({
23 name: "getWeather",
24 arguments: { city: "北京" },
25});
26Resources
Server端注册资源(Resources)
工具 = 让 AI 写/做事, 资源 = 让 AI 读数据。
1// ResourceTemplate 用来定义带变量的 URI 模式
2import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
3import fs from "node:fs/promises";
4import path from "node:path";
5
6const NOTES_DIR = path.join(process.cwd(), "notes");
7
8mcpServer.registerResource(
9 "note", // 资源类型名(类似数据库表名)
10 // URI 模板:{filename} 是占位符,匹配 notes://MCP.md 这种具体 URI
11 new ResourceTemplate("notes://{filename}", {
12 // list 回调:让 Client 能枚举所有可用资源(不返回内容,只返回 metadata)
13 // 这样 AI 能先「看到目录」再决定读哪个,不会把所有数据塞进 context
14 list: async () => {
15 const files = await fs.readdir(NOTES_DIR);
16 const mdFiles = files.filter((f) => f.endsWith(".md"));
17 return {
18 resources: mdFiles.map((name) => ({
19 uri: `notes://${name}`,
20 name,
21 description: `学习笔记:${name.replace(/\.md$/, "")}`,
22 mimeType: "text/markdown",
23 })),
24 };
25 },
26 }),
27 // 资源元数据:description 和 mimeType 给 Client UI 看
28 {
29 description: "学习笔记",
30 mimeType: "text/markdown",
31 },
32 // 读取回调:Client 调 readResource(uri) 时触发
33 // uri 是完整 URL 对象,variables 是从模板里抽出来的 { filename: "MCP.md" }
34 async (uri, variables) => {
35 const filename = String(variables.filename);
36 if (!/^[\w.-]+\.md$/.test(filename)) {
37 throw new Error(`非法笔记文件名:${filename}`);
38 }
39 const filepath = path.join(NOTES_DIR, filename);
40 const text = await fs.readFile(filepath, "utf-8");
41 return {
42 contents: [
43 {
44 uri: uri.href,
45 mimeType: "text/markdown",
46 text,
47 },
48 ],
49 };
50 }
51);
52Client端读取资源
1
2// 第一步:列出 Server 暴露的所有资源 metadata
3// 返回 [{ uri: "notes://MCP.md", name, description, mimeType }, ...]
4const { resources } = await client.listResources();
5
6// 第二步:按 URI 读取具体一份资源的内容
7const result = await client.readResource({ uri: "notes://MCP.md" });
8
9// contents 是数组(一份资源可以由多块拼成)
10// 取第一块的 text 字段就是 markdown 全文
11const text = result.contents[0].text;
12静态资源 vs 动态资源
资源有两种注册模式。
静态资源:URI 写死
1mcpServer.registerResource(
2 "appConfig",
3 "config://app/settings", // ← 固定字符串 URI
4 { description: "应用配置" },
5 async (uri) => ({
6 contents: [{ uri: uri.href, text: JSON.stringify(myConfig) }],
7 })
8);特点:
- 就一份资源
- list 由 SDK 自动生成
- 不用写 list 回调
动态资源:URI 模板 + 枚举回调
笔记那个例子就是动态的:一个 notes://{filename} 模板对应无穷多份资源(按文件名)。SDK 不知道实际有哪些文件,所以必须写 list 告诉它。这就是为什么动态资源要 new ResourceTemplate,而静态资源不用。
判断标准:URI 数量是不是 1 个。1 个 → 静态;多个 → 动态。
让 LLM 调用 Resources(桥接方案)
因为OpenAI/DeepSeek协议只认tools, 不认识MCP Resources。 所以我们只能用桥接方案:把listResources / readResource(这两个为自定义的名称) 包成虚拟 tool。
1
2// 虚拟 tool 1:listNotes(无参数)
3// LLM 看到这个 tool 就知道「我能列笔记」
4{
5 name: "listNotes",
6 description: "列出所有笔记",
7 // parameters 必须是合法的 JSON Schema 对象
8 // 即使没参数也要写空 properties,否则 OpenAI 会拒绝
9 parameters: { type: "object", properties: {} },
10}
11
12
13
14// 虚拟 tool 2:readNote(需要 filename 参数)
15{
16 name: "readNote",
17 description: "读取笔记",
18 parameters: {
19 type: "object",
20 properties: { filename: { type: "string" } }, // 参数定义
21 required: ["filename"], // 必填
22 },
23}
24
25
26// 分发函数:LLM 决定调哪个 tool 后,由它分流到 MCP API
27async function dispatch(name, args) {
28 // 命中虚拟 tool → 走 Resource API
29 if (name === "listNotes") return await client.listResources();
30 if (name === "readNote") return await client.readResource({ uri: `notes://${args.filename}` });
31 // 其他都是真 tool → 走 Tool API
32 return await client.callTool({ name, arguments: args });
33
34}Prompts (MCP 第三能力)
服务端预定义的「带参数对话模板」。 用户主动选择,不是 LLM 自己挑。
Server 端注册 Prompt
1mcpServer.prompt(
2 "summarizeNote", // prompt 名(用户能看到的)
3 "用 5 个要点总结指定的学习笔记", // 描述(用户选的时候参考)
4 // 参数 schema:用户/UI 必须先填这些参数
5 { filename: z.string().describe("笔记文件名") },
6 // 模板渲染逻辑:拿到参数后构造一段 messages
7 async ({ filename }) => {
8 // Prompt 可以调用 Resource、做计算,组合最终 prompt
9 const filepath = path.join(NOTES_DIR, filename);
10 const content = await fs.readFile(filepath, "utf-8");
11 return {
12 description: `总结 ${filename}`, // 给 UI 显示
13 // 关键:返回的是 messages 数组,结构和聊天历史一样
14 // 可以是单条 user 消息,也可以是 user/assistant 多轮
15 messages: [
16 {
17 role: "user",
18 content: {
19 type: "text", // 也支持 image / audio / resource
20 text:
21 `请用 5 个要点总结以下学习笔记:\n\n` +
22 `--- ${filename} ---\n${content}`,
23 },
24 },
25 ],
26 };
27 }
28);
29Client 端调用 Prompt
1// 1. 列出所有可用 prompts(拿 metadata,不渲染)
2const { prompts } = await client.listPrompts();
3// → [{ name, description, arguments: [{name, description, required}] }]
4
5// 2. 渲染指定 prompt
6const result = await client.getPrompt({
7 name: "summarizeNote",
8 arguments: { filename: "MCP.md" }, // 必须匹配 schema
9});
10// → { description, messages: [{role, content}, ...] }
11
12// 3. 把 messages 直接喂给 LLM
13const response = await openai.chat.completions.create({
14 model: "deepseek-chat",
15 messages: result.messages, // ← 直接当对话历史用
16});getPrompt 返回什么
1const result = await mcpClient.getPrompt({
2 name: "summarizeNote",
3 arguments: { filename: "MCP.md" },
4});简化后的返回结构:
1{
2 description?: string, // 这次渲染的描述(给 UI 用)
3 messages: Array<{
4 role: "user" | "assistant",
5 content:
6 | { type: "text", text: string } // 文本(最常用)
7 | { type: "image", data: string, mimeType: string } // 图片(base64)
8 | { type: "audio", data: string, mimeType: string } // 音频
9 | { type: "resource", resource: { uri, text/blob } } // 内嵌资源全文
10 | { type: "resource_link", uri, name, ... } // 资源链接(懒加载)
11 }>
12}Server 端返回什么 → Client 端拿到什么。 中间走协议是透明的。
设计意图:返回结构故意做得像 OpenAI/Anthropic 的 messages,就是为了能直接当对话历史塞给 LLM。
桥接到只支持文本的模型时,要先简化非 text 类型:
1for (const m of result.messages) {
2 const text =
3 m.content.type === "text"
4 ? m.content.text
5 : `[非文本内容: ${m.content.type}]`;
6 promptMessages.push({ role: m.role, content: text });
7}三大能力的调用方对照
| 能力 | 谁选/调 | 触发时机 |
|---|---|---|
| Tools | LLM 自主决定 | 对话中按需调用 |
| Resources | 用户/应用提供 | 对话开始前注入上下文 |
| Prompts | 用户从清单选 | 启动一段对话 |
Claude Desktop 里的体现:
- Tools → AI 自动调
- Resources → @ 引用
- Prompts → / slash 命令
Tools vs Resources 设计差异
| 维度 | Tools | Resources |
|---|---|---|
| 语义 | 动作(POST) | 数据(GET) |
| 副作用 | 可能有 | 应该没有 |
| 参数 | 任意 schema | URI + 模板变量 |
| 缓存 | 不该缓存 | 适合缓存 |
| 典型场景 | 调 API、写文件 | 文档、配置、知识库 |
经验法则:
- 「执行...」「调用...」→ Tool
- 「读取...」「查看...」→ Resource
Transport 选哪个
| Transport | 场景 |
|---|---|
| InMemory | 同进程调试、教学 |
| Stdio | 本地子进程(Claude Desktop 主用) |
| StreamableHTTP | 跨网络远程调用 |
| SSE | 老版本 HTTP 流 |
Stdio Transport:跨进程跑 MCP Server
InMemory 是教学用的。生产场景下 server 通常跑在独立进程里——这才是 Claude Desktop / Cursor 接入 MCP server 的方式。
写一个独立的 server 脚本
1// scripts/mcp-stdio-server.ts
2import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3import { mcpServer } from "../lib/mcp-server";
4
5async function main() {
6 // StdioServerTransport 接管 process.stdin / process.stdout 作为协议通道
7 const transport = new StdioServerTransport();
8 await mcpServer.connect(transport);
9
10 // ★ 关键纪律:日志只能走 stderr(console.error)
11 // stdout 是协议通道,写一个非 JSON 字符 client 就解析失败
12 console.error("[server] connected");
13}
14
15main().catch((err) => {
16 console.error("fatal:", err);
17 process.exit(1);
18});Client 端 spawn 子进程
1import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
2
3const transport = new StdioClientTransport({
4 command: process.platform === "win32" ? "npx.cmd" : "npx",
5 args: ["tsx", "scripts/mcp-stdio-server.ts"],
6 stderr: "inherit", // 子进程 stderr 转父进程终端,方便看日志
7});
8
9const client = new Client({ name: "...", version: "1.0.0" });
10await client.connect(transport); // 内部触发 spawn
11
12// 后续用法和 InMemory 完全一样
13const { tools } = await client.listTools();
14const result = await client.callTool({...});Stdio 三个必踩的坑
console.log是死罪:stdout 是协议通道,污染就崩。所有日志走console.error。- Windows 上 command 要带
.cmd:直接写"npx"Windows 上 spawn 找不到。 - 子进程要用 globalThis 缓存复用:每次请求都 spawn 要付 ~1.5s 启动成本,复用后第二次起降到几毫秒。
InMemory vs Stdio
| 维度 | InMemory | Stdio |
|---|---|---|
| 隔离 | 共享内存 | 独立进程 |
| 性能 | μs 级 | ms 级(IPC 序列化) |
| 崩溃影响 | 同归于尽 | server 崩了不影响 client |
| 部署 | 必须同代码库 | server 可独立部署 |
| 语言 | 必须同语言 | server 可以是任何语言 |
| 适用 | 自家应用内的工具 | 接 Claude Desktop、用第三方 server |
MCP tool → OpenAI tool 转换
LLM API 不直接懂 MCP,要中间翻译一层。
1
2// 从 MCP Server 拿到所有 tool 的 metadata
3const { tools } = await client.listTools();
4// 把 MCP 格式 → OpenAI function calling 格式
5const openaiTools = tools.map((t) => ({
6 type: "function", // OpenAI 要求固定字段
7 function: {
8 name: t.name, // 直接复用
9 description: t.description ?? "", // 描述影响 LLM 是否调用
10 parameters: t.inputSchema, // MCP 的 inputSchema 本身就是 JSON Schema
11 // 所以可以直接塞进去,不用转换
12 },
13}));
14完整调用流程
1用户提问
2 ↓
3listTools() → 拿到工具列表
4 ↓
5调 LLM (带 tools)
6 ↓
7LLM 返回 tool_calls
8 ↓
9mcpClient.callTool(name, args)
10 → Transport 转发到 Server
11 → Server 执行
12 → 结果回传
13 ↓
14工具结果塞回 history
15 ↓
16再调 LLM (这次 stream=true)
17 ↓
18流式输出最终回复外层套个 while 循环 = Agentic Loop。
用别人写好的 MCP Server
这是 MCP 真正的杀手价值——别人写好 server,你直接接进自己应用。
例子:接入官方文件系统 server
1const transport = new StdioClientTransport({
2 command: process.platform === "win32" ? "npx.cmd" : "npx",
3 args: [
4 "-y",
5 "@modelcontextprotocol/server-filesystem",
6 path.join(process.cwd(), "notes"), // 限定 server 只能访问 notes 目录
7 ],
8 stderr: "inherit",
9});
10
11const client = new Client({ name: "my-app", version: "1.0.0" });
12await client.connect(transport);
13
14const { tools } = await client.listTools();
15// → 14 个工具凭空出现:
16// read_file / read_text_file / read_multiple_files
17// write_file / edit_file / create_directory / move_file
18// list_directory / directory_tree / search_files
19// get_file_info / list_allowed_directories ...我们没写一行 fs.readFile,但 AI 现在能读、写、改、搜整个 notes/ 目录。
官方 servers 一览
Anthropic 维护:
@modelcontextprotocol/server-filesystem— 文件系统读写@modelcontextprotocol/server-github— GitHub repo 操作@modelcontextprotocol/server-postgres— SQL 查询@modelcontextprotocol/server-memory— AI 长期记忆@modelcontextprotocol/server-puppeteer— 浏览器自动化@modelcontextprotocol/server-brave-search— 网页搜索
社区还有上千个,npm 上搜 mcp-server- 就行。
自己 server vs 别人 server,代码差别只有这一行
| 自己写 | 别人的 | |
|---|---|---|
command | tsx | npx |
args | ["scripts/mcp-stdio-server.ts"] | ["-y", "@xxx/server-yyy", "..."] |
| 后续调用 | 一字不变 | 一字不变 |
反过来:自己写的 server 也能给别人用
scripts/mcp-stdio-server.ts 配进 Claude Desktop:
1// claude_desktop_config.json
2{
3 "mcpServers": {
4 "ai-blog-studio": {
5 "command": "npx",
6 "args": ["tsx", "/path/to/scripts/mcp-stdio-server.ts"]
7 }
8 }
9}重启 Claude Desktop,你的工具 + 资源 + prompts 全部进入 Claude 客户端。同一份 server 同时给自家 Web 和 Claude 客户端用——这才是 MCP 设计的终极目标。
坑:Already connected to a transport
报错
1Error: Already connected to a transport.
2Call close() before connecting to a new transport,
3or use a separate Protocol instance per connection.根因
MCP 是有状态会话协议。 一个 McpServer 实例只能连一个 transport。 不是普通无状态 RPC。 Next.js 两个特性踩这个坑:
| 行为 | 后果 |
|---|---|
| 每次请求执行路由函数 | 重复 connect → 炸 |
| dev 模式 HMR 重载模块 | new McpServer 多次 |
解决方法
1. Client 复用(解决多请求)
1// 把缓存挂在 globalThis 上:HMR 重载模块时它不会被清空
2// as unknown as 是 TS 的双重断言,告诉编译器「我知道我在干啥」
3const globalForMcp = globalThis as unknown as { mcpClient?: Client };
4
5async function getMcpClient() {
6 // 已有连接直接返回,不重新建 transport
7 if (globalForMcp.mcpClient) return globalForMcp.mcpClient;
8 // 第一次才真正建 Client + transport pair
9 const client = new Client({...});
10 const [c, s] = InMemoryTransport.createLinkedPair();
11 // 必须并行 connect,握手才能完成
12 await Promise.all([client.connect(c), mcpServer.connect(s)]);
13
14 // 存到全局,下次直接复用
15 globalForMcp.mcpClient = client;
16 return client;
17}2. Server 单例 + 注册去重(解决 HMR)
1
2// 同样把 Server 实例挂到 globalThis
3const globalForMcp = globalThis as unknown as { mcpServer?: McpServer };
4
5// ?? 短路:有就用旧的,没有才 new 一个
6// 这样 HMR 重新执行模块时,仍指向同一个 Server 实例
7
8export const mcpServer = globalForMcp.mcpServer ?? new McpServer({...});
9
10// 关键:判断这次是不是第一次初始化
11// HMR 重载时 globalForMcp.mcpServer 还在 → isNew = false
12const isNew = !globalForMcp.mcpServer;
13globalForMcp.mcpServer = mcpServer; // 立刻挂回去,给下一次重载用
14
15// 只在首次注册工具,会触发
16if (isNew) {
17 mcpServer.registerResource(...); // 只注册一次
18 mcpServer.tool(...);
19}
20为什么用 globalThis
模块级单例 ≠ 全局单例。 HMR 会重新执行模块代码, 模块顶层变量被重置。 globalThis 跨 HMR 保留。 Next.js 里跑「有状态连接」的标配:
- 数据库连接池(Prisma 官方就这么干)
- 消息队列
- MCP Client / Server
我的理解
写 MCP server = 给生态贡献能力。 用 MCP server = 站在巨人肩膀上。
之前 ChatGPT 时代每接一个工具都要重写一遍, MCP 之后,一个包名 + 一行 args,AI 立刻多一双手。