40岁的中年程序员面试 AI Agent 开发:第一篇,用 Bun 实现安全的文件 MCP 工具集
面试官:为什么用 Bun 不用 Node.js?
我:Bun 启动比 Node.js 快 5 倍,冷启动从 300ms 降到 60ms,文件读写性能高出 30%-50%。写工具类代码也比 Node.js 少一半样板代码。
面试官 :那 AI 编程助手要读、写、改项目里的文件,但绝对不能让它删 C:\Windows 或读 ~/.ssh/id_rsa。你怎么设计这套工具集?
我 :核心思路是嵌入式 MCP 工具集------不走 stdio 通信,就是一个普通 TypeScript 模块,函数调用直接执行。7 个工具,不到 300 行代码,覆盖 90% 的文件操作场景。
面试官追问:MCP 是什么?
我 :MCP 全称 Model Context Protocol ,Anthropic 在 2024 年推的,核心思想是把工具调用标准化。
它定义了一个 JSON Schema 描述工具能力:
bash
{
"name": "read_file",
"description": "读取文件内容",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "文件路径" }
},
"required": ["path"]
}
}
面试官追问:为什么不用现成的 MCP server?
我:三个原因:
所以我的策略是:自己写一个嵌入式 MCP 工具集,注册到 AI 网关里,不走 stdio / socket,就是一个普通 TypeScript 模块。
面试官追问:安全怎么保证?
我 :这是整个工具集最关键的一处。做法是维护一个全局白名单根目录,所有路径必须落在它下面。
bash
let allowedRoot = "";
export function setAllowedRoot(path: string) {
allowedRoot = path;
}
function resolvePath(inputPath: string): string {
const resolved = inputPath.startsWith("/") || inputPath.match(/^[a-zA-Z]:\\/)
? inputPath
: join(allowedRoot, inputPath);
if (allowedRoot && !resolved.startsWith(allowedRoot)) {
throw new Error(`权限拒绝: 路径 ${resolved} 不在项目目录 ${allowedRoot} 内`);
}
return resolved;
}
逻辑两步:
allowedRoot 什么时候设置?用户在前端"打开项目"时调用 setAllowedRoot。之后 AI 调任何工具,路径都被这条线拦着。
这个校验不是完美的,比如符号链接、
..\这种边界情况,但对个人 IDE 来说够用。
面试官追问:图片文件怎么处理?
我 :read_file 需要同时支持文本和图片。图片转 base64 返回,多模态模型可以直接识别。
bash
read_file: async (args) => {
const path = resolvePath(args.path as string);
const mime = getImageMime(path);
if (mime) {
const buf = await readFile(path);
const b64 = buf.toString("base64");
return { content: [{ type: "text", text: `data:${mime};base64,${b64}` }] };
}
const content = await readFile(path, "utf-8");
return { content: [{ type: "text", text: content }] };
}
getImageMime 是后缀名查表:
bash
const IMAGE_MIME: Record<string, string> = {
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
gif: "image/gif", webp: "image/webp", bmp: "image/bmp",
ico: "image/x-icon", svg: "image/svg+xml", avif: "image/avif",
};
返回 data:image/png;base64,iVBORw0KG... 这种格式,OpenAI / Anthropic 的多模态接口都认。
面试官追问:write_file 和 edit_file 有什么区别?
我 :两个工具看起来都是"写文件",但职责完全不同,互相不替代:
bash
write_file: async (args) => {
const path = resolvePath(args.path as string);
await mkdir(dirname(path), { recursive: true });
const exists = await stat(path).catch(() => null);
if (exists) throw new Error(`文件已存在: ${path}。如需修改请用 edit_file 工具。`);
await writeFile(path, args.content as string, "utf-8");
return { content: [{ type: "text", text: `文件创建成功: ${path}` }] };
},
edit_file: async (args) => {
const path = resolvePath(args.path as string);
const exists = await stat(path).catch(() => null);
if (!exists) throw new Error(`文件不存在: ${path}。如需新建请用 write_file 工具。`);
const original = await readFile(path, "utf-8");
await writeFile(path, args.content as string, "utf-8");
return {
content: [{
type: "text",
text: `文件编辑成功: ${path}\n原大小: ${original.length} 字符\n新大小: ${(args.content as string).length} 字符`
}],
};
}
设计意图:
-
•
write_file已存在就报错 → 防止 AI 误覆盖已有文件 -
•
edit_file不存在也报错 → 防止 AI 写到一个错误路径 -
• 错误信息里直接告诉 AI 应该用哪个工具,AI 不用猜,直接重试
write_file 自动 mkdir -p 中间目录({ recursive: true }),AI 创建新文件时经常给一个深层路径,父目录不存在就报错,这个细节很关键。
面试官追问:搜索代码怎么实现的?
我 :不自己实现搜索,直接调 ripgrep。Bun.spawn 启动子进程,比 Node.js 简洁很多。
bash
search_code: async (args) => {
const root = args.path ? resolvePath(args.path as string) : allowedRoot;
const keyword = args.keyword as string;
const results: { file: string; line: number; content: string }[] = [];
try {
const proc = Bun.spawn(["rg", "--line-number", "--no-heading", "--with-filename", keyword, "./"], {
cwd: root,
stdout: "pipe",
stderr: "ignore",
});
const text = await new Response(proc.stdout).text();
for (const line of text.split("\n")) {
if (!line.trim()) continue;
const idx1 = line.indexOf(":");
if (idx1 === -1) continue;
const file = line.slice(0, idx1);
const rest = line.slice(idx1 + 1);
const idx2 = rest.indexOf(":");
if (idx2 === -1) continue;
const lineNum = Number(rest.slice(0, idx2));
const content = rest.slice(idx2 + 1);
results.push({ file: relative(root, file), line: lineNum, content });
}
} catch { }
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
}
为什么用 ripgrep?
-
• 极快(Rust 写的,比 grep/ack/ag 都快)
-
• 默认忽略
.gitignore、二进制文件、隐藏文件 -
• 输出格式稳定:
file:line:content,非常好解析
Bun.spawn 的 stdout: "pipe" 拿到 stdout 之后,直接 new Response(proc.stdout).text() 把整个流读完。这个语法只在 Bun 里有,用 Node API 还得包 readable 流。
面试官追问:工具怎么注册到 AI 网关?
我:需要转换成 OpenAI tools 协议的格式,AI 网关才能识别。
bash
export function toOpenAiTools(): unknown[] {
return FILE_TOOLS.map((t) => ({
type: "function",
function: {
name: t.name,
description: t.description,
parameters: { ...t.inputSchema },
},
}));
}
然后在 AI 网关里:
bash
const tools = toOpenAiTools();
const body = JSON.stringify({ model, messages, tools, stream: false, tool_choice: "auto" });
AI 收到 tools 列表,就能决定调哪个、传什么参数。
工具执行入口也很简单:
bash
export async function executeToolCall(name: string, args: Record<string, unknown>): Promise<string> {
const tool = getTool(name);
if (!tool) return `错误: 未知工具 "${name}"`;
try {
const result = await tool.handler(args);
return result.content.map((c) => c.text).join("\n");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return `工具执行错误: ${msg}`;
}
}
设计原则:
面试官追问:为什么不做通用 Shell 执行工具?
我 :这个问题我认真考虑过。给 AI 一个 run_command 工具,什么都能干,但那是 RCE 级别的风险。任何 AI 输出的 shell 命令,我都得人工 review 才能执行,反而失去了"自动化"的意义。
所以我只暴露"项目内文件操作",AI 想跑命令,得让用户自己在终端跑。这是 IDE 的责任边界,不能模糊。
其他几个设计决策:
-
• 为什么不用第三方 MCP SDK? 有现成的
@modelcontextprotocol/sdk,但它是给独立 MCP server 用的,走 stdio/websocket。我这里是嵌入式调用,同一个进程内直接调函数,强行用 SDK 反而要套一层客户端-服务端,复杂又没收益。 -
• 工具集要不要更通用? 我考虑过加
git_commit、terminal_run、http_request等,结论是先不加。工具越多,AI 选错的概率越大;工具越具体,AI 调用越准确。
面试官:总结一下,文件 MCP 工具集的核心设计是什么?
我:三句话概括:
整套工具集 7 个工具,不到 300 行代码,零外部依赖,跑在 Bun 里性能不是问题。MCP 协议本身很简单,难的是想清楚"哪些操作该暴露、哪些不该,错误怎么处理,安全怎么保证"。