过去一年多,我做 AI 应用开发的起点都是同一个需求:让模型别只会聊天,去真正干活——查库、读文件、调内部接口。这个需求的传统解法是 Function Calling(函数调用):在请求里描述一堆函数,模型决定调哪个、传什么参,客户端负责执行再把结果喂回去。
能用,但有两个问题一直烦人:各家模型的 Function Calling 格式互不兼容;工具本身无法复用,换个 Host(宿主应用)就得把胶水代码重写一遍。
MCP(Model Context Protocol,模型上下文协议)就是为解决这两件事而来的。这篇文章我用 TypeScript 从零写一个能被 Claude Desktop、Cursor 等客户端直接调用的 MCP Server——一个本地 Markdown 笔记检索服务。全文代码可以直接跑,读完你能得到:协议核心概念、一个完整 Server 实现、以及我踩过的三个坑。
版本说明:Node 20+、@modelcontextprotocol/sdk 1.x、zod 3.x。协议规范迭代较快,以官方文档为准,客户端与服务端会在初始化阶段协商协议版本。
MCP 是什么:先说结论
MCP 的本质,是把"AI 应用如何发现和调用外部工具"这件事,标准化成了一个基于 JSON-RPC 2.0 的开放协议。
官方定义:MCP 是一个开放协议,为 AI 应用连接外部数据源和工具提供标准化接口。—— modelcontextprotocol.io
它定义了三个角色:
- Host(宿主) :AI 应用本体,比如 Claude Desktop、Cursor、你自己写的 Chat 客户端
- Client(客户端) :Host 内部为每个 Server 维护的协议连接,负责 JSON-RPC 消息收发
- Server(服务端) :暴露工具和数据的服务进程,比如我们下面要写的笔记检索服务
一次典型的调用流程是:Host 把用户请求发给模型,模型根据上下文决定需要某个工具,Host 通过 Client 向 Server 发起 tools/call 请求,Server 执行后返回结果,结果回填给模型继续推理。
[图片:Host-Client-Server 三者关系与一次工具调用的时序图,建议尺寸 900×383]
Server 可以对外暴露三类能力:
- Tools(工具) :可被模型调用的函数,比如"搜索笔记"、"创建工单"
- Resources(资源) :可被读取的数据,比如文件内容、数据库表结构
- Prompts(提示模板) :预置的提示词模板,供用户主动调用
传输层(transport)有两种:stdio 用于本地进程通信,Server 作为子进程启动;Streamable HTTP 用于远程服务,支持鉴权与多客户端。本文用 stdio,够简单也够典型。
看到这里你可能会问:这跟 Function Calling 有什么区别?一句话:Function Calling 是"模型的能力",MCP 是"生态的约定" 。前者解决模型怎么决定调函数,后者解决工具怎么被所有客户端复用——你写一次 Server,任何支持 MCP 的 Host 都能直接用,不用再写一行集成代码。
动手:写一个笔记检索 Server
1. 初始化项目
bash
mkdir mcp-notes && cd mcp-notes
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node
目录结构:
text
mcp-notes/
├── server.ts # Server 本体
└── ~/notes/ # 被检索的笔记目录,放几个 .md 文件即可
2. 核心代码
先上完整实现,不到 60 行:
ts
// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import fs from "node:fs/promises";
import path from "node:path";
// 被检索的笔记目录,按需修改
const NOTES_DIR = path.join(process.env.HOME ?? ".", "notes");
const server = new McpServer({
name: "notes-search",
version: "0.1.0",
});
server.tool(
"search_notes", // 工具名:模型用它来定位工具
"在本地 Markdown 笔记中按关键词全文检索,返回文件名和上下文片段",
{
// 参数 schema 用 zod 描述,SDK 会转成 JSON Schema 发给客户端
keyword: z.string().describe("要检索的关键词"),
},
async ({ keyword }) => {
const files = (await fs.readdir(NOTES_DIR)).filter((f) => f.endsWith(".md"));
const hits: string[] = [];
for (const f of files) {
const content = await fs.readFile(path.join(NOTES_DIR, f), "utf-8");
const idx = content.indexOf(keyword);
if (idx !== -1) {
const start = Math.max(0, idx - 40);
hits.push(`**${f}** ...${content.slice(start, idx + keyword.length + 60)}...`);
}
}
return {
content: [{ type: "text", text: hits.length ? hits.join("\n\n") : "没有匹配的笔记" }],
};
}
);
// stdio 传输:作为子进程被 Host 启动,通过标准输入输出收发消息
const transport = new StdioServerTransport();
await server.connect(transport);
几个关键点:
server.tool()的四个参数:工具名、描述、参数 schema、实现函数。描述是写给模型看的,它直接决定模型什么时候调用这个工具、怎么传参(这是坑三的伏笔)- 参数校验用 zod(一个 TypeScript 优先的 schema 校验库),SDK 自动把它转成 JSON Schema 随
tools/list返回给 Host - 返回值是
content数组,支持text/image/audio等类型,本文只用到文本
3. 接到客户端上
以 Claude Desktop 为例,编辑配置文件(macOS 路径是 ~/Library/Application Support/Claude/claude_desktop_config.json):
json
{
"mcpServers": {
"notes-search": {
"command": "npx",
"args": ["-y", "tsx", "/absolute/path/to/mcp-notes/server.ts"]
}
}
}
重启 Claude Desktop,输入框旁出现工具图标,就说明 Server 已被发现。直接问它:"帮我找一下笔记里提到 MCP 的内容",就能看到完整的调用链路。
Cursor 的接法一样,把同样的 mcpServers 结构写进项目根目录的 .cursor/mcp.json 即可。这就是 MCP 的价值:一次实现,处处可用——同一个 Server,两个客户端零代码接入。
[图片:Claude Desktop 中的调用效果截图,建议尺寸 900×383]
踩坑记录
真实项目里,协议代码往往不是难点,难点在这些边角。
坑一:console.log 把协议搞坏了
- 现象:Server 单独跑没问题,接进 Claude Desktop 后工具不出现
- 排查:打开
~/Library/Logs/Claude/目录下的mcp-server-notes-search.log,发现协议初始化阶段就断连 - 原因:stdio 传输下,标准输出 stdout 就是协议通道。我在代码里随手打了一行
console.log("启动成功"),这行字符串对 JSON-RPC 解析器来说就是非法消息,直接导致连接崩溃 - 解法:所有调试日志改用
console.error(走 stderr,不影响协议),或封装一个写文件的 logger。这应该作为 stdio Server 的编码纪律
坑二:npx 冷启动超时
- 现象:配置里用
npx -y tsx启动,客户端偶尔提示连接超时 - 排查:手动计时
npx -y tsx server.ts,冷启动要下载依赖,网络差时超过 10 秒 - 原因:Host 对 Server 启动有时间预期,npx 首次运行的包下载开销不可控
- 解法:发布场景先
tsc编译成 JS 用node直接启动;开发场景先全局安装tsx,让启动路径不再依赖网络
坑三:模型不调用工具,或乱传参数
- 现象:工具明明注册了,模型要么无视它直接编答案,要么传了奇怪的关键词
- 排查:把工具的 description 从"搜索笔记"改成一句话场景描述后,调用率明显变化
- 原因:工具的 name 和 description 本质上是 prompt 的一部分。描述含糊,模型就无法判断"什么时候该用我"
- 解法:description 写成"动作 + 范围 + 返回什么"的完整句子;用 zod 的
.describe()给每个参数补充语义。把工具描述当作 API 文档,写给一个不了解你系统的新同事看
总结
- MCP 把工具调用标准化为基于 JSON-RPC 2.0 的开放协议,核心角色是 Host / Client / Server,Server 暴露 Tools、Resources、Prompts 三类能力
- 用官方 TypeScript SDK 写一个 stdio Server 不到 60 行,重点是
server.tool()的四要素:名称、描述、schema、实现 - stdio 传输下 stdout 是协议通道,调试日志必须走 stderr
- 工具描述是 prompt 的一部分,写得越具体,模型调用越准
适用边界:如果你的应用只对接单一模型、工具只在内部用,直接用 Function Calling 更简单,不必为了 MCP 而 MCP;当工具需要跨客户端复用(比如多个 AI 应用共享一套内部能力),或者你想接入现成的社区 Server 生态时,MCP 标准化的收益才真正体现出来。
协议与 SDK 都在快速迭代,文中代码以官方文档为准:modelcontextprotocol.io。