MCP 开发实战:把你自己的后端 API 接入 Agent

本文写的也是 通俗实战版。去掉了繁琐的工程踩坑流水账、Node/TS 模块机制细节与单元测试源码,保留全部核心设计思想、多模态看图登录方案与开箱即用代码,大概15分钟左右 轻轻松松即可读懂 MCP 并把你自己的后端 API 变成 AI 随叫随到的工具。


一、为什么有了 Function Calling,还要学 MCP?

做过 Agent 或用过 AI 编程工具的大家都有过一个痛点:每换一个 AI 客户端,就要把同一批接口重新接一遍。

假设你自己搭了一个书签收藏站(下文叫 linkbox),平时把好文章、好视频都往里塞。你想让 AI 帮你管书签:「帮我看看稍后阅读里最新收藏的视频,把它复制到前端收藏夹」。

  • 只用 Function Calling 时 :函数的定义和调用代码都写死在某一个应用里。你在自己的聊天网页里写好了「查书签」,换到 Cursor 里想用,得按 Cursor 的格式重写一遍;换到 Claude Desktop,又得再配一遍。如果有 MM M 个客户端、 NN N 个服务,一共要写 M×NM \times N M×N 份适配代码。
  • 用 MCP(Model Context Protocol)时 :MCP 就像给 AI 工具统一了 Type-C 接口标准 。你只需要把自己的后端 API 包装成一个 MCP Server (写 1 次),任何支持 MCP 的客户端(Cursor、Claude Desktop、MCP Inspector)都能直接插上来用,集成成本直接从 M×NM \times N M×N 降到 M+NM + N M+N。

这里还有一个核心工程经验:只要你的系统有 API,就直接用 MCP 包 API,千万别让 Agent 去模拟浏览器点点点。 页面 DOM 结构改个样式就会挂,而后端 API 的入参和返回永远确定、速度快还省 Token。

graph LR U[&#34;你:帮我看看稍后阅读里最新收藏的视频&#34;] --> C[&#34;AI 客户端<br/>Cursor / Claude Desktop&#34;] C <-->|&#34;MCP 标准协议(stdio)&#34;| S[&#34;你的 MCP Server<br/>(不到 100 行 TS 代码)&#34;] S <-->|&#34;HTTP 请求 + Token&#34;| A[&#34;你的后端 API<br/>(linkbox 书签站)&#34;]

二、核心认知:MCP Server 到底在干什么?

很多人第一次看 MCP 文档会被各种概念绕晕。其实官方文档里,MCP Server 能提供的三类能力非常清晰:

能力类型 通俗理解 谁决定什么时候用?
Tools(工具,本文主角) AI 可以主动调用的函数(如「查书签」「复制书签」) 大模型自己决定(写操作通常需用户点批准)
Resources(资源) 只读的参考资料(如日志文件、接口文档) 用户或客户端主动挂载
Prompts(提示词模板) 预设好的快捷指令模板 用户手动选择触发

我们要实现「让 AI 调用自己的 API」,只需要用到 Tools。

一个最小可用的本地 MCP Server,工程结构只需要 3 个文件,各管一件事:

plain 复制代码
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 行代码:

typescript 复制代码
// 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 翻译成明确的中文语义:

typescript 复制代码
// 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 节选)

这里展示最具代表性的两个工具------查询并瘦身书签 (读操作)与跨收藏夹复制书签(写操作):

typescript 复制代码
// 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: &#34;8&#34; }) 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,彻底杜绝密码泄露。

核心实现代码如下:

typescript 复制代码
// 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");
}
typescript 复制代码
// 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):

json 复制代码
{
  "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..."
      }
    }
  }
}

一行命令打开可视化调试面板,挨个点击测试每个工具的入参和返回:

bash 复制代码
npx @modelcontextprotocol/inspector --config config.json

6.2 生产接入:编译后挂到 Agent 中使用

测试通过后,执行 npx tsc 编译出 dist/index.js,把它加进 Cursor(.cursor/mcp.json)或 Claude Desktop 等配置文件里即可日常使用:

json 复制代码
{
  "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 条检查项,写完代码对照扫一眼能省下几个小时的排查时间:

  1. stdio 模式下绝对不能用 console.log : 连上 StdioServerTransport 后,stdout 就是 JSON-RPC 协议通信管道!你在代码里写一句 console.log("调试信息"),客户端就会把它当成损坏的 JSON 报文导致断连报错。打日志请一律改用 console.error() (输出到 stderr,不影响协议)。

  2. 绝不要把 tsx watch 放进客户端配置 : tsx watch 会监听 stdin(标准输入)并在收到输入时重启进程、往 stdout 打印重启提示。这会和 MCP 的 stdio 通信直接冲突,导致每发一条请求 Server 就重启一次。

  3. 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 最稳,而且启动速度快一倍以上。

  4. 工具数量控制在 10 个以内,命名风格要统一 : 模型每次对话都要通读所有工具的描述,工具越多越容易选错;工具命名统一用下划线(如 list_bookmarks、search_bookmarks),并且描述必须写清查的是「收藏夹」还是「书签」,别让两个工具的描述撞车。

  5. 新项目一律用 server.registerTool() : 旧教程里的 server.tool(name, desc, schema, fn) 按位置传参写法在 SDK v1.x 中已标记为 @deprecated,并在 SDK v2 中被正式移除。直接用配置对象式的 server.registerTool(),后续加 annotations 或升级版本都不用返工。

相关推荐
OpenAnolis小助手1 小时前
龙蜥社区亮相 GOSIM AI Vision Forum 共话“Agentic Native”开源新
人工智能·ai·开源·操作系统·龙蜥社区·agent
Bode_20022 小时前
机器人装配线的监测和预测
人工智能·机器人装配
Zhao-Jichao2 小时前
AI Prompting for Everyone 课程笔记 - 03 联网搜索 Web Search
人工智能
xsd202411182 小时前
手机扫一眼盘子算食物体积方案
人工智能
阿里云基础软件2 小时前
Agent 任务提速 10.71%,内存占用降低 21.60%|Alibaba Cloud Linux 4 重大发布
java·linux·人工智能·操作系统·agent
服务器硬件项目经理2 小时前
《超节点修仙录》· 第一卷·炼气期【第04话】 超节点 vs 普通服务器——不就是多插了几张卡吗?
运维·服务器·人工智能·嵌入式硬件·深度学习·机器学习
前端 贾公子2 小时前
将CHATGPT/Codex 26.727.51351固定在MacOS Monterey 12上
人工智能
9i编程2 小时前
用 Codex 改代码,依据是什么?
人工智能·openai·ai编程