本文写的也是 通俗实战版。去掉了繁琐的工程踩坑流水账、Node/TS 模块机制细节与单元测试源码,保留全部核心设计思想、多模态看图登录方案与开箱即用代码,大概15分钟左右 轻轻松松即可读懂 MCP 并把你自己的后端 API 变成 AI 随叫随到的工具。
一、为什么有了 Function Calling,还要学 MCP?
做过 Agent 或用过 AI 编程工具的大家都有过一个痛点:每换一个 AI 客户端,就要把同一批接口重新接一遍。
假设你自己搭了一个书签收藏站(下文叫 linkbox),平时把好文章、好视频都往里塞。你想让 AI 帮你管书签:「帮我看看稍后阅读里最新收藏的视频,把它复制到前端收藏夹」。
- 只用 Function Calling 时:函数的定义和调用代码都写死在某一个应用里。你在自己的聊天网页里写好了「查书签」,换到 Cursor 里想用,得按 Cursor 的格式重写一遍;换到 Claude Desktop,又得再配一遍。如果有 个客户端、 个服务,一共要写 份适配代码。
- 用 MCP(Model Context Protocol)时:MCP 就像给 AI 工具统一了 Type-C 接口标准。你只需要把自己的后端 API 包装成一个 MCP Server(写 1 次),任何支持 MCP 的客户端(Cursor、Claude Desktop、MCP Inspector)都能直接插上来用,集成成本直接从 降到 。
这里还有一个核心工程经验:只要你的系统有 API,就直接用 MCP 包 API,千万别让 Agent 去模拟浏览器点点点。 页面 DOM 结构改个样式就会挂,而后端 API 的入参和返回永远确定、速度快还省 Token。
graph LR
U["你:帮我看看稍后阅读里最新收藏的视频"] --> C["AI 客户端<br/>Cursor / Claude Desktop"]
C <-->|"MCP 标准协议(stdio)"| S["你的 MCP Server<br/>(不到 100 行 TS 代码)"]
S <-->|"HTTP 请求 + Token"| A["你的后端 API<br/>(linkbox 书签站)"]
二、核心认知:MCP Server 到底在干什么?
很多人第一次看 MCP 文档会被各种概念绕晕。其实官方文档里,MCP Server 能提供的三类能力非常清晰:
| 能力类型 | 通俗理解 | 谁决定什么时候用? |
|---|---|---|
| Tools(工具,本文主角) | AI 可以主动调用的函数(如「查书签」「复制书签」) | 大模型自己决定(写操作通常需用户点批准) |
| Resources(资源) | 只读的参考资料(如日志文件、接口文档) | 用户或客户端主动挂载 |
| Prompts(提示词模板) | 预设好的快捷指令模板 | 用户手动选择触发 |
我们要实现「让 AI 调用自己的 API」,只需要用到 Tools。
一个最小可用的本地 MCP Server,工程结构只需要 3 个文件,各管一件事:
linkbox-mcp/
├── src/
│ ├── index.ts # 1. 启动入口:创建 McpServer,挂上 stdio 通信通道(仅 10 行)
│ ├── client.ts # 2. 接口封装:统一发 fetch 请求、带 Authorization 头、处理报错
│ └── tools.ts # 3. 工具定义:写清工具名、描述、参数 Schema,调接口返回数据
├── package.json # 依赖只需 @modelcontextprotocol/sdk 和 zod,加上 "type": "module"
└── tsconfig.json # 设 "target": "ES2022"、"module": "Node16"、"outDir": "dist"
⚙️ 新手配置提示:因为下面用到了顶层
await和 Node 原生 ESM 模块,package.json里记得加一行"type": "module";而在tsconfig.json开启"module": "Node16"后,TS 文件里的本地相对导入需写.js后缀(如import { registerTools } from "./tools.js")。
先看入口文件 src/index.ts,一共不到 10 行代码:
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registerTools } from "./tools.js";
const server = new McpServer({ name: "linkbox", version: "0.2.0" });
registerTools(server);
// 用标准输入/输出(stdio)和 AI 客户端通信
await server.connect(new StdioServerTransport());
💡 什么是
stdio?为什么本地工具首选它?stdio是 Standard Input / Output(标准输入 / 标准输出) 的缩写。平时写 Web 服务要先监听一个端口(如:3000)再发 HTTP 请求;而在操作系统里,任何命令行进程(如node dist/index.js)天生就自带三根默认管道:
stdin(标准输入):接收外部输入数据的管道;stdout(标准输出):往外输出正常结果的管道(平时console.log走的就是它);stderr(标准错误):输出错误与调试日志的独立管道(对应console.error)。采用
stdio模式时,不用开端口、不用配域名、不用部署服务器!Cursor 或 Claude Desktop 会直接把你的脚本作为本地子进程拉起,往stdin塞 JSON-RPC 请求、从stdout读返回结果,就像接了一根本地专属传声筒,随用随启。
三、把 API 变成好用的 AI 工具:四条黄金法则
AI 看不到你的业务代码,它只能看到三样东西:工具名、工具描述、参数 Schema(含每个参数的 .describe())。
接口文档写错了,同事会来问你;工具描述写错了,AI 只会默默传错参数或者胡乱重试。在实际封装 linkbox 的 4 个书签工具时,最管用的就是下面这四条设计法则:
| 黄金法则 | 反面教材(直接照搬后端接口) | 正面实践(面向 AI 友好设计) | 为什么这样做? |
|---|---|---|---|
| 1. 参数描述给具体例子 | z.string().describe("收藏夹名称") | z.string().describe("收藏夹名,如 稍后阅读、前端") | 给一个真实示例,比写十句抽象定义更能让模型一次填对 |
| 2. 把接口“方言”藏在代码里 | 让 AI 传 q: "title=~MCP" 或 from: "稍后阅读/1024" | 让 AI 只传关键词 q: "MCP" 或分开传 collection + id,代码里再去拼接 | 让 AI 填最符合自然语言直觉的值,格式拼接全部留在代码里 |
| 3. 返回数据先“瘦身” | 把后端返回的 20 个字段(含内部 ID、0001-01-01 假时间、字节数)原样丢给 AI | 只留 7 个核心字段,标签拍平成字符串数组,字节换算成 3.3 (MB) | 工具返回的每个字符都占上下文、算 Token 费;剔除噪音不仅省钱,还能防 AI 被假时间误导 |
| 4. 报错要告诉 AI“下一步干嘛” | 直接抛 401 Unauthorized(AI 会换个参数继续傻试) | 返回 isError: true,内容写明:「token 已过期,请先调用 get_login_captcha 获取验证码,再调用 login 登录」 | 把死报错变成可操作的行动指令,模型就能看懂并自己走完恢复流程 |
四、开箱即用:请求封装与工具定义核心代码
遵循上面的四条法则,来看看核心代码怎么写。
4.1 统一请求层(src/client.ts)
负责拼接 URL、自动携带 Authorization 头,并把 HTTP 401/403 翻译成明确的中文语义:
// src/client.ts
const BASE = process.env.LINKBOX_BASE ?? "http://localhost:8080";
let sessionToken = "";
// 自动补齐 Bearer 前缀,兼容浏览器复制的完整串与登录接口返回的裸 JWT
export function setSessionToken(token: string) {
const value = token.trim();
sessionToken = !value ? "" : value.startsWith("Bearer ") ? value : `Bearer ${value}`;
}
setSessionToken(process.env.LINKBOX_TOKEN ?? "");
export async function linkboxApi<T = any>(
path: string,
req: { method?: string; query?: Record<string, any>; body?: unknown } = {},
): Promise<T> {
const method = req.method ?? (req.body !== undefined ? "POST" : "GET");
const headers: Record<string, string> = {};
// ⚠️ 认证接口(取验证码、登录)本身就是用来换新 token 的,绝不能带过期的旧 token
const skipAuth = path.startsWith("/auth/");
if (!skipAuth && sessionToken) headers.Authorization = sessionToken;
const queryStr = req.query
? `?${new URLSearchParams(Object.entries(req.query).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]))}`
: "";
const res = await fetch(`${BASE}${path}${queryStr}`, {
method,
headers: req.body !== undefined ? { ...headers, "Content-Type": "application/json" } : headers,
body: req.body !== undefined ? JSON.stringify(req.body) : undefined,
});
if (res.status === 401 || res.status === 403) {
if (skipAuth) throw new Error(`登录失败:${res.status} ${await res.text()}`);
throw new Error("token 无效或已过期");
}
if (!res.ok) throw new Error(`请求失败:${res.status} ${await res.text()}`);
const text = await res.text();
return (text ? JSON.parse(text) : undefined) as T;
}
export const linkboxOk = (data: unknown) => ({
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
});
4.2 工具定义层(src/tools.ts 节选)
这里展示最具代表性的两个工具——查询并瘦身书签(读操作)与跨收藏夹复制书签(写操作):
// src/tools.ts(节选)
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { linkboxApi, linkboxOk } from "./client.js";
// 把异常转为带操作指引的 isError 结果,引导 AI 自救
function asError(err: unknown) {
const text = err instanceof Error ? err.message : String(err);
return {
isError: true,
content: [{
type: "text" as const,
text: text.includes("token 无效")
? "token 无效或已过期,请先调用 get_login_captcha 获取验证码,算出结果后调用 login 重新登录,再重试刚才的操作"
: text,
}],
};
}
export function registerTools(server: McpServer) {
// 1. 读工具:列出书签,并在返回前做字段裁剪与单位换算
server.registerTool(
"list_bookmarks",
{
description: "列出某个收藏夹里的所有书签,按收藏时间从新到旧排列(第一条即最新)",
inputSchema: {
collection_name: z.string().describe("收藏夹名,如 稍后阅读"),
page: z.number().int().min(1).default(1),
page_size: z.number().int().min(1).max(100).default(20),
},
// readOnlyHint: true 告诉客户端这是纯查询(只读、不改数据),客户端可据此免弹窗自动放行
annotations: { readOnlyHint: true },
},
async ({ collection_name, page, page_size }) => {
try {
const { data } = await linkboxApi("/api/linkbox/bookmarks-page-list", {
query: { collection_name, page, page_size },
});
// 只保留 AI 回答问题需要的 7 个字段,大幅节省 Token
const items = data.map((b: any) => ({
id: b.id,
title: b.title,
url: b.url,
tags: b.tags.map((t: any) => t.name), // 对象数组拍平为 ["MCP", "前端"]
created_time: b.created_time,
visited_time: b.visited_time,
snapshot_mb: +(Number(b.snapshot_size) / 1024 / 1024).toFixed(1), // 字节转 MB
}));
return linkboxOk(items);
} catch (err) {
return asError(err);
}
},
);
// 2. 写工具:拆开复合参数,并用 ID 精确定位防止误操作
server.registerTool(
"copy_bookmark",
{
description: "把指定 ID 的书签从来源收藏夹复制到目标收藏夹。会真实写入数据,调用前请先向用户确认。",
inputSchema: {
from_collection_name: z.string().describe("来源收藏夹名,如 稍后阅读"),
bookmark_id: z.number().int().describe("书签ID,如 1024(可从 list_bookmarks 结果中获取)"),
to_collection_name: z.string().describe("目标收藏夹名,如 前端"),
},
// readOnlyHint: false 告诉客户端该工具会真实修改/写入数据,提醒客户端在调用前弹窗让用户确认
annotations: { readOnlyHint: false },
},
async ({ from_collection_name, bookmark_id, to_collection_name }) => {
try {
// 后端要求传 "收藏夹名/书签ID",在代码里拼接,不让 AI 自己拼
const res = await linkboxApi("/api/linkbox/bookmarks-copy", {
method: "POST",
body: {
from: `${from_collection_name}/${bookmark_id}`,
collection_name: to_collection_name,
},
});
return linkboxOk(res);
} catch (err) {
return asError(err);
}
},
);
}
五、进阶实战:遇到图形验证码?让多模态 AI 自己看图登录!
调用自己的后台接口,最头疼的就是登录态(Token)过期。
第一版我用最笨的办法:从浏览器 F12 复制 Bearer <JWT> 塞进环境变量。但 Token 一过期就得重新打开网页登录、复制、改配置、重启服务,比直接在网页上点书签还累。想写死自动登录吧,后台模板自带了算术图形验证码(如 3+5=?)+ RSA 密码加密。
后来我灵机一动:验证码防的是传统死脚本,可现在的大模型天生能看图!而且 MCP 工具不仅能返回文本,还能直接返回 image 图片!
只要把登录拆成两个工具,遵守一条铁律——「AI 只负责看图算数,账号密码只存在环境变量里绝不经过 AI」,就能实现全自动续期:
sequenceDiagram
participant AI as AI 模型(多模态)
participant S as MCP Server(本地进程)
participant B as 你的后端 API
AI->>S: 1. 调 list_bookmarks 收到「token 过期,请先取验证码登录」
AI->>S: 2. 调用 get_login_captcha()
S->>B: GET /auth/code
B-->>S: 返回 uuid + base64 验证码图片
S-->>AI: 返回 MCP image 内容(算术题图片)
Note over AI: 3. 模型看图算出 3+5 = 8
AI->>S: 4. 调用 login({ code: "8" })
Note over S: 从环境变量读账号密码 + RSA 公钥加密
S->>B: POST /auth/login(账号、密文、code=8、uuid)
B-->>S: 返回新 accessToken
Note over S: 存入内存 sessionToken
S-->>AI: 登录成功!
AI->>S: 5. 自动重试 list_bookmarks,拿到书签结果
🔒 为什么账号密码绝不能作为
login工具的参数让 AI 传? 因为工具参数不是私密通道!如果让 AI 传password,密码就会进入大模型上下文、显示在客户端审批弹窗里,还会被写进聊天历史日志。把账号密码放在 MCP 配置的env环境变量里,由 Server 内部读取并用 Node 原生crypto.publicEncrypt加密,AI 全程只看到一个验证码数字8,彻底杜绝密码泄露。
核心实现代码如下:
// 1. RSA 密码加密(对齐前端 jsencrypt 默认的 PKCS#1 v1.5 填充)
import { constants, createPublicKey, publicEncrypt } from "node:crypto";
export function encryptPassword(password: string, publicKeyBase64: string): string {
const key = createPublicKey({ key: Buffer.from(publicKeyBase64, "base64"), format: "der", type: "spki" });
// ⚠️ Node 默认是 OAEP 填充,必须显式指定 RSA_PKCS1_PADDING 后端才能解开
return publicEncrypt({ key, padding: constants.RSA_PKCS1_PADDING }, Buffer.from(password, "utf8")).toString("base64");
}
// 2. 获取验证码图片 + 自动登录工具(src/tools.ts)
let lastCaptchaUuid = "";
// 工具五:把验证码作为 MCP image 返回给大模型看
server.registerTool(
"get_login_captcha",
{
description: "获取登录验证码图片(算术题)。先调用本工具看图算出结果,再把结果作为 code 调用 login。不确定时先问用户,不要乱猜。",
inputSchema: {},
},
async () => {
try {
const { data } = await linkboxApi<{ data: { uuid: string; img: string } }>("/auth/code");
lastCaptchaUuid = data.uuid;
// 剥离 "data:image/...;base64," 前缀,提取纯 base64
const base64Data = data.img.replace(/^data:[^;]+;base64,/, "");
return {
content: [
{ type: "text" as const, text: "请看图算出算术题结果,作为 code 参数调用 login" },
{ type: "image" as const, mimeType: "image/png", data: base64Data },
],
};
} catch (err) {
return asError(err);
}
},
);
// 工具六:AI 只传验证码计算结果 code,账号密码由 Server 从环境变量读取
server.registerTool(
"login",
{
description: "用验证码结果登录。必须先调用 get_login_captcha;账号密码由 server 从环境变量读取,无需你提供。",
inputSchema: {
code: z.string().describe("验证码算术题的计算结果,如 8"),
},
},
async ({ code }) => {
if (!lastCaptchaUuid) return asError("请先调用 get_login_captcha 获取验证码");
try {
const res = await linkboxApi("/auth/login", {
method: "POST",
body: {
username: process.env.LINKBOX_USERNAME,
password: encryptPassword(process.env.LINKBOX_PASSWORD!, process.env.LINKBOX_PUBLIC_KEY!),
code,
uuid: lastCaptchaUuid,
},
});
setSessionToken(res.data.accessToken);
return linkboxOk({ ok: true, message: "登录成功,请重试刚才的操作" });
} catch (err) {
return asError(err);
} finally {
lastCaptchaUuid = ""; // 验证码一次性有效,用完即焚
}
},
);
(注:这套看图登录仅限用于你自己搭建的服务和个人账号;长期来看,给自己的后端加一个长期有效的 API Token 依然是最稳妥的方案。)
六、3 分钟跑起来:调试与接入客户端
6.1 开发调试:用 Inspector + tsx 免编译秒级验证
改完 TypeScript 代码如果每次都要 npm run build 再去重启 Cursor,效率太低了。推荐安装 tsx(npm i -D tsx),配合官方调试神器 MCP Inspector(相当于 MCP 版的 Postman)直接运行源码。
先在项目根目录建一个 config.json(含密码,务必加入 .gitignore):
{
"mcpServers": {
"linkbox-dev": {
"command": "node",
"args": ["--import", "tsx", "src/index.ts"],
"cwd": "/你的项目绝对路径/linkbox-mcp",
"env": {
"LINKBOX_BASE": "http://localhost:8080",
"LINKBOX_USERNAME": "your_username",
"LINKBOX_PASSWORD": "your_password",
"LINKBOX_PUBLIC_KEY": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..."
}
}
}
}
一行命令打开可视化调试面板,挨个点击测试每个工具的入参和返回:
npx @modelcontextprotocol/inspector --config config.json
6.2 生产接入:编译后挂到 Agent 中使用
测试通过后,执行 npx tsc 编译出 dist/index.js,把它加进 Cursor(.cursor/mcp.json)或 Claude Desktop 等配置文件里即可日常使用:
{
"mcpServers": {
"linkbox": {
"type": "stdio",
"command": "node",
"args": ["/你的项目绝对路径/linkbox-mcp/dist/index.js"],
"env": {
"LINKBOX_BASE": "http://localhost:8080",
"LINKBOX_USERNAME": "${env:LINKBOX_USERNAME}",
"LINKBOX_PASSWORD": "${env:LINKBOX_PASSWORD}",
"LINKBOX_PUBLIC_KEY": "${env:LINKBOX_PUBLIC_KEY}"
}
}
}
}
现在,你只需要在对话框里说一句:「帮我看看稍后阅读里最近收藏了什么,把最新那篇关于 MCP 的文章复制到前端收藏夹」,AI 就会自己完成「发请求 → 发现未登录 → 拉取验证码 → 看图算出结果 → 登录拿 Token → 查书签 → 复制书签」的全套动作。
七、上线前必看的 5 条避坑清单
最后把我在实战中踩过的坑浓缩成 5 条检查项,写完代码对照扫一眼能省下几个小时的排查时间:
-
stdio模式下绝对不能用console.log: 连上StdioServerTransport后,stdout就是 JSON-RPC 协议通信管道!你在代码里写一句console.log("调试信息"),客户端就会把它当成损坏的 JSON 报文导致断连报错。打日志请一律改用console.error()(输出到stderr,不影响协议)。 -
绝不要把
tsx watch放进客户端配置:tsx watch会监听stdin(标准输入)并在收到输入时重启进程、往stdout打印重启提示。这会和 MCP 的stdio通信直接冲突,导致每发一条请求 Server 就重启一次。 -
Windows 下
command优先写node,别直接写npx: 在 Windows 上npx实际是npx.cmd批处理文件,Cursor / Claude Desktop 等 Agent 直接拉起子进程时常会报spawn npx ENOENT。用"command": "node", "args": ["--import", "tsx", "src/index.ts"]或直接跑编译后的dist/index.js最稳,而且启动速度快一倍以上。 -
工具数量控制在 10 个以内,命名风格要统一: 模型每次对话都要通读所有工具的描述,工具越多越容易选错;工具命名统一用下划线(如
list_bookmarks、search_bookmarks),并且描述必须写清查的是「收藏夹」还是「书签」,别让两个工具的描述撞车。 -
新项目一律用
server.registerTool(): 旧教程里的server.tool(name, desc, schema, fn)按位置传参写法在 SDK v1.x 中已标记为@deprecated,并在 SDK v2 中被正式移除。直接用配置对象式的server.registerTool(),后续加annotations或升级版本都不用返工。