本文写的也是 通俗实战版。去掉了繁琐的工程踩坑流水账、Node/TS 模块机制细节与单元测试源码,保留全部核心设计思想、多模态看图登录方案与开箱即用代码,大概15分钟左右 轻轻松松即可读懂 MCP 并把你自己的后端 API 变成 AI 随叫随到的工具。
一、为什么有了 Function Calling,还要学 MCP?
做过 Agent 或用过 AI 编程工具的大家都有过一个痛点:每换一个 AI 客户端,就要把同一批接口重新接一遍。
假设你自己搭了一个书签收藏站(下文叫 linkbox),平时把好文章、好视频都往里塞。你想让 AI 帮你管书签:「帮我看看稍后阅读里最新收藏的视频,把它复制到前端收藏夹」。
- 只用 Function Calling 时:函数的定义和调用代码都写死在某一个应用里。你在自己的聊天网页里写好了「查书签」,换到 Cursor 里想用,得按 Cursor 的格式重写一遍;换到 Claude Desktop,又得再配一遍。如果有 \(M\) 个客户端、\(N\) 个服务,一共要写 \(M \times N\) 份适配代码。
- 用 MCP(Model Context Protocol)时:MCP 就像给 AI 工具统一了 Type-C 接口标准。你只需要把自己的后端 API 包装成一个 MCP Server(写 1 次),任何支持 MCP 的客户端(Cursor、Claude Desktop、MCP Inspector)都能直接插上来用,集成成本直接从 \(M \times N\) 降到 \(M + N\)。
这里还有一个核心工程经验:只要你的系统有 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 能提供的三类能力非常清晰:
我们要实现「让 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 个书签工具时,最管用的就是下面这四条设计法则:
四、开箱即用:请求封装与工具定义核心代码
遵循上面的四条法则,来看看核心代码怎么写。
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 或升级版
本文由 AI图鉴自动抓取并翻译,版权归原作者所有;如需查看原文请点下方链接。
It's also written in the popular field. The cumbersome project peddler book, the details of the Node/TS module mechanism and the unit test source code were removed, the entire core design idea, the multi-modular view log-in scheme and the open-box code were kept, about 15 minutes later, to read MCP and turn your own back end API into an AI-at-call tool.
I. Why did you learn MCP?
Everyone who has done Agent or has used the AI programming tool has had a pain point: every time an AI client is changed, the same interface must be reconnected.
Suppose you set up a bookmark collection (hereinafter "linkbox" ) with good articles, good videos. You want AI to take care of your bookmarks, "Look at the latest collection of videos I'll read later, copy it to the front collection."
- When using Function Calling only: both the definition of function and the call code are written in a given application. You wrote "bookmarks" in your chat page, changed to Cursor, rewritten in Cursor; changed to Claude Desktop, and it's worth it again. If there are \(M\\) client, \(n) service, write \(M \times\) matching code.
- When using MCP (Model Context Protocol): MCP harmonizes the Type-C interface standards like an AI tool. All you have to do is wrap your API backend into a MCP Server (1 writing) and any client who supports MCP (Cursor, Claude Desktop, MCP Inspect) can be plugged in and integrate costs down from (M\times \) to (M+ \).
And here's the core engineering experience: as long as your system has an API, use the MCP package API, so don't let Agent go to simulate the browser point. The page DOM structure changes the style to hang, while the input and return of the back end API is always fixed, fast enough.
graph LR
U["You: Take a look at the latest collection of videos from later on"-----C["AI client<br/>Cursor/Claude Desktop"]
C <-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
S <------------------------- +----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Core Awareness: What is MCP Server doing?
For the first time, many people look at MCP documents and they're going to go around all kinds of concepts. In fact, the three kinds of capabilities that MCP Server can offer in official files are very clear:
We're going to make "AAI call its own API" and just need to use Tools.
A minimum available local MCP Server, the project structure requires three files, one thing each:
linkbox-mcp/
├── src/
│-index.ts#1. Launch entrance: Create McpServer, hang the stdio communication channel (lines only)
│ - client.ts #2. Interface wrapping: Unise fech requests, bring Autoration headers, handle reports Wrong.
│ - tools.ts#3. Tool definition: Write Toolname, Description, Parameters Schema, Switch Interface Back Data
@modercontextprotocol/sdk and Zod, plus "type": "module"
{\cHFFFFFF}{\cH00FFFF} {\cHFFFFFF}{\cH00FFFF} {\cHFFFFFF}{\cH00FFFF} {\cHFFFFFF}{\cH00FFFF} {\cHFFFFFF}{\cH00FFFF} {\cHFFFFFF}{\cH00FFFF}{\cH00FF00} {\cHFFFFFF}{\cH00FF00} {\cHFFFFFF}{\cH00FF00} {\cHFFFFFF}{\cH00FF00} {\cHFFFFFF}{\cH00FF00} {\cHFFFFFF}{\cH00FF00}
⚙️ beginner configuration tips: local relative import in TS files needs to write.js suffix (e.g. Import {registerTools} from "./tools.js) after tsconfig.json opens "module": "Node16".
Look first at the entry files src/index.ts.
// 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 and AI client communications
await server.connect(new StdioServerTransport());
What is stdio? Why do local tools choose it?
stdio is an abbreviation for Standard Input / Output. Usually write Web services to listen to a port (e. g. 3,000) before sending HTTP requests; in the operating system, any command line process (e. g. node dist/ index.js) is born with three default conduits:
- stdin (standard input): the conduit for receiving external input data;
- stdout (Standard output): the conduit for the output of normal results to the outside (in normal time console.log)
-Stderr (standard error): output error and debug log stand-alone conduit (console.error).
When applying the stdio mode, no starter, no domain name, no server deployment! Cursor or Claude Desktop pulls your script directly as a local sub-process, requests it to stdin and reads it back from stdout, as if it were a local exclusive microphone.
III. AI Tools for turning API into a good use: four golden laws
AI cannot see your business code, and it can only see three things: a tool name, a tool description, parameters Schema (.describe() with each parameter).
The interface document is wrong, and colleagues will ask you; the tool description is wrong, and AI will pass the wrong parameters or mess with retry. In the four bookmarks that actually contain Linkbox, the following four design codes are most useful:
Open the box to use: request for sealing and tool definition core code
Follow the four rules above and see how the core code works.
4.1 Uniform request layer (src/client.ts)
Query URL, carry AutoAuthorization head and translate HTTP 401/403 into a clear Chinese syntax:
// src/client.ts
const BASE = process.env.LINKBOX_BASE ?? "http://localhost:8080";
let sessionToken = "";
/ / Auto-complete Bearer prefix, full string compatible with browser copy and naked JWT returned from login interface
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> = {};
/ / ⚠️ Authentication interface (takes the authentication code, login) is itself used to replace a new token and must not carry expired old 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 (`Listing failure: $(res.status}${await res.text()});
Throw new Error ("Token is invalid or expired");
}
If (!res.ok) throw new Error (`failed request: $(res.status}${awaitres.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 Tool definition layers (src/tools.ts extract)
The two most representative tools are shown here — query and thin bookmarks (reading operations) and cross-receiving bookmarks (writing operations):
/ src/tools.ts (extract)
import { z } from "zod";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { linkboxApi, linkboxOk } from "./client.js";
/ Turning anomalies into operational guidance is Errror.
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 invalid")
♪ "Token is invalid or expired, call first get login captcha to get the authentication code, calculate the result and call login to login again, and then retry just did it"
: text,
}],
};
}
export function registerTools(server: McpServer) {
/ 1. Reading tool: list bookmarks and do field cropping and unit convert before returning
server.registerTool(
"list_bookmarks",
{
"list all bookmarks in a collection folder, new to old (art. 1, latest),
inputSchema: {
"Collection name: z.string().describe" ("Prevenue alias, e.g. read later"),
page: z.number().int().min(1).default(1),
page_size: z.number().int().min(1).max(100).default(20),
},
/ readOnlyHint: true tells the client that this is a pure query (read-only, without changing data) against which the client can release automatically without a bullet window
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 },
});
/ / Only 7 field required for AI to answer questions, with significant savings token
const items = data.map((b: any) => ({
id: b.id,
title: b.title,
url: b.url,
tags: b.tags.map ((t: any)=>t.name), / / Object array equals ["MCP", "frontend"]
created_time: b.created_time,
visited_time: b.visited_time,
snapshot mb: + (Number(b.snapshot size) / 1024 / 1024).toFixed(1), /byte rotation MB
}));
return linkboxOk(items);
} catch (err) {
return asError(err);
}
},
);
/ 2. Writing tool: untangle composite parameters and use ID to position accurately against error
server.registerTool(
"copy_bookmark",
{
Description: " Copy the bookmarks from the source collection to the target collection. The data is actually written, and please confirm to the user before calling." I don't know.
inputSchema: {
From collection name: z.stream().describe (“source collection combination, e.g. read later”),
Bookmark id: z.number().int() describe("bookmark ID, e.g. 1024 (available from list bookmarks results”),
To collection name: z.string().describe("Target Collection Plugin, e.g. Frontend"),
},
/ readOnlyHint: false tells the client that the tool will really change/write the data, and reminds the client to make sure the user is sure that the bullet window before calling
annotations: { readOnlyHint: false },
},
async ({ from_collection_name, bookmark_id, to_collection_name }) => {
try {
/ / Backend requests "A Collection Plugin/Bookmark ID" to spell in code and not allow AI to spell itself
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);
}
},
);
}
V. Progress in action: Having a graphic validation code? Let Multimodel AI see the login!
Call your own back-office interface, the most painful is the expiration of the login state (Token).
The first version I'm using is the dumbest way to copy Bearer < JWT> from browser F12 into the environment variable. Token, however, has to re-open, copy, configure, and restart the service as soon as it expires, which is more tiring than putting bookmarks directly on the web page. If you want to write an automatic login, the backstage template contains a graphic calculator code (e.g. 3+5=?) + RSA password encryption.
Then I moved: the proof code is a traditional dead script, but now LLM is a natural map! And MCP tools can return not only text, but also image!
Once the login is broken down into two tools and the iron law is followed - "AI is only responsible for looking at the numbers, the account code is only available in the environment variables and never goes through the AI" - the full automatic extension is achieved:
sequenceDiagram
Participant AI as AI model (multimodular)
Party S as MCP Server (local process)
Party B as your back end API
AI->S: 1. Transfer list bookmarks
AI->S: 2. Call get login captcha()
S->>B: GET /auth/code
B->S: returns uuid + base64 authentication code picture
S->AI: returns MCP content (image of arithmetic)
Note over AI: 3. Modelling 3+5 = 8
AI->S: 4. Call login ({code: "8"})
Note over S: Read account code from environment variables + RSA public key encryption
S->B: POST/auth/login (account number, secret, code = 8, uuid)
B->S: returns new accessToken
Note over S: deposit memory session Token
Login successful!
AI->S: 5. Automatic retry list bookmarks, get bookmark results
🔒 Why can't the account code be passed on to AI as a parameter for the login tool?
Because the tool parameter is not a private channel. If AI passes password, the password enters LLMcontext, shows it in the client approval window, and is entered in the chat history log. Place the account password in the env environment variable configured by MCP, read and encrypted internally by Server and using the Node native crypto.publicEncrypt. AI sees only one authentication code number8 throughout the process, completely eliminating password leaks.
Core realization codes are as follows:
/ / 1. RSA password encryption (the default PKCS#1 v1.5 fill for aligning frontend jsencrypt)
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 default is OAEP fill, which must be clearly specified as RSA PKCS1 PADING backend to untangle
return publicEncrypt({ key, padding: constants.RSA_PKCS1_PADDING }, Buffer.from(password, "utf8")).toString("base64");
}
// 2. Get a picture of the authentication code + auto-entry tool (src/tools.ts)
let lastCaptchaUuid = "";
// Tool 5: return the authentication code as MCP image to LLM
server.registerTool(
"get_login_captcha",
{
Description: " Get a login authentication code picture (characteristics). Use this tool to calculate the results, then use the results as code call login. Ask the user when you're not sure." I don't know.
inputSchema: {},
},
async () => {
try {
const { data } = await linkboxApi<{ data: { uuid: string; img: string } }>("/auth/code");
lastCaptchaUuid = data.uuid;
// Strip "data:image/...;base64" prefix, extract pure base64
const base64Data = data.img.replace(/^data:[^;]+;base64,/, "");
return {
content: [
{ type: "text" as const, text: "Look at the result of the calculation algorithm as a code parameter calling login"},
{ type: "image" as const, mimeType: "image/png", data: base64Data },
],
};
} catch (err) {
return asError(err);
}
},
);
/ / Tool VI: AI only transmits the authentication code calculation code code code, account code read from the environment variable by Server
server.registerTool(
"login",
{
Description: " Login with the authentication code. You must first call get login captcha; the account code is read by server from the environment variable, without you." I don't know.
inputSchema: {
code: z.string().describe("Certificate algorithmics, e.g. 8"),
},
},
async ({ code }) => {
If (!lastCaptchauid) return as Error ("get login captcha for authentication code first");
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: “Loging in successful, please retry just operation});
} catch (err) {
return asError(err);
} finally {
LastCaptchaUuid = "; / /Certificate Code is valid once and burn immediately after use
}
},
);
(Note: The login is limited to the services and personal accounts you build; in the long run, an API Token is still the surest solution for the back end. I'm not sure.
Six, three minutes running: debugging and accessing clients End
6.1 Development debugging: Validation using Inspectr + tsx uncompiled seconds
If you change the TypeScript code every time you need a npm run build to restart Cursor, it's too inefficient. It is recommended to install tsx (npm i-D tsx) to run the source directly with the official debugger MCP Inspector (equivalent to the MCP version Postman).
First create a config.json in the root directory (with passwords to add.gitignore):
{
"mcpServers": {
"linkbox-dev": {
"command": "node",
"args": ["--import", "tsx", "src/index.ts"],
"cwd": "/Your project absolute path/linkbox-mcp",
"env": {
"LINKBOX_BASE": "http://localhost:8080",
"LINKBOX_USERNAME": "your_username",
"LINKBOX_PASSWORD": "your_password",
"LINKBOX_PUBLIC_KEY": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A..."
}
}
}
}
One line of commands opens the visual debugging panel and is clicked to test the inclusion and return of each tool:
npx @modelcontextprotocol/inspector --config config.json
6.2 Production access: used in compiled and mounted on Agent
Following the test, perform npx tsc to compile dist/index.js and add it to configuration files such as Cursor (.cursor/mcp.json) or Claude Desktop for daily use:
{
"mcpServers": {
"linkbox": {
"type": "stdio",
"command": "node",
"args": [/Your project absolute path/linkbox-mcp/list/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}"
}
}
}
}
Now, all you have to say in the dialogue box is, "Look what's recently stored in my reading later, copy the latest article on the MCP to the front end collection folder," and AI will complete the full set of actions to "send the request plaque and find no log-in to pull the authentication code to see the results to log in token and bookmark and copy."
VII. List of 5 holes to be seen before going online
Finally, the pits I stepped on in the field were condensed into five tests, and a code-checking exercise saved hours of time:
-Stdio can't be used in any way.
When you connect to StdioServerTransport, stdout is the JSON-RPC protocol communication channel! You write in the code a console.log, and the client treats it as a broken JSON message that leads to a false reporting. The log should always be changed to Console.error() (output to stderr, without prejudice to the protocol).
- Never put tsx watch into client configuration: tsx watch listens to stdin (standard input) and restarts the process when you receive input, printing a restart hint to stdout. This would be a direct conflict with the MCP's stdio communication, leading to the resumption of every request by Server.
Node, not npx:
npx is actually a npx.cmd batch file on Windows, Cursor / Claude Desktop and Agent regularly reports spawn npx ENOENT when they pull up directly. "command": "node", "args": [-import", "tsx", "src/index.ts"] or "dist/index.js" directly compiled, are the most stable and more than double the start-up speed.
- The number of tools is within 10 and the style of the name is consistent:
Models read the description of all tools every time they talk, and the more tools the tools are wrongly selected; the tools are given a uniform way of underlined (e.g. list bookmarks, search bookmarks) and describe whether the "repository folder" or "bookmarks" must be written, so that the descriptions of both tools do not crash.
- New projects are always served as server.registerTool():
Server.tool (name, desc, scheme, f) is marked in SDK v1.x by place transfer and officially removed in SDK v2. Directly configure object-type server.registerTool(), followed by annotations or upgrades Version
Auto-fetched and translated by AI Atlas. All rights belong to the original author.