我想让 AI 看本地私有代码,结果第一版差点写成“给 AI 发钥匙”
这事是我前几天顺手折腾的一个小项目。
不是做 Agent,不是接模型接口,也不是折腾什么花活 UI。
我就想做个本地 MCP 服务,让 AI 能看我机器上一份私有业务代码。
要求听着很朴素:
- 能知道项目目录长什么样
- 能读指定文件
- 能搜代码
- 但别给我乱跑 shell
- 也别顺手把
.env、.git/config这种东西翻出来
我一开始真觉得这题不大。
起个 Node 服务,挂个 stdio,塞几个工具,不就完了。
结果一上手就开始拐弯。
先是服务明明启动了,协议却被我自己打脏了。
然后我又差点把“只读上下文”写成“给 AI 发一套远程排障工具箱”。
这俩坑,一个土,一个危险。
也是被这两下敲了,我才把这个项目从“看起来挺能干”收成了“边界还算老实”。
这题我一开始就想歪了
刚动手的时候,我脑子里冒出来的全是工具名:
read_filelist_dirsearch_codegit_diff
再往后一点,我甚至都快写出 run_shell 了。
那会儿我完全没觉得哪儿不对。
AI 缺什么,我就补什么,像给同事配一套远程排查工具。
写着写着我才开始犯嘀咕。
我明明想做的是“让 AI 读私有代码”,怎么味儿慢慢往“给 AI 更大的主动权”那边滑了。
尤其一想到 run_shell 真接进去,我自己都开始冒冷汗。
我当时一直以为问题在能力不够。
翻来翻去才反应过来,不是能力,是边界先歪了。
这个题先该问的不是“AI 还能多干点什么”,而是“我到底只想让它看到什么”。
想明白这句之后,整个项目我就开始收。
收完之后,我只留了四个面:
repo://overviewrepo://tree/{path}repo://file/{path}search_code
看着抠,心里倒是踏实。
第一个坑土得我不太想承认:我把 stdout 当日志台用了
服务刚起那阵,我碰到一个很烦的现象。
终端上看,服务像是正常启动了。
可一接宿主或者 Inspector,消息就是不对劲。
我第一反应很标准:
- 是不是 JSON-RPC 格式写错了
- 是不是 SDK 哪个 schema 没对齐
- 是不是 transport 初始化顺序有问题
我顺着这条路查了半天,越查越烦。
因为每个地方看着都像没问题。
最后回头看入口,我人有点傻。
我写了这么一句:
await server.connect(transport);
console.log("MCP server started");
对,锅就是它。
MCP 走 stdio 的时候,stdout 不是给我拿来打日志的,那就是协议通道。
我这一句 console.log,等于自己往消息流里倒了口沙子。
改完之后才正常:
const transport = new StdioServerTransport();
await server.connect(transport);
process.stderr.write(`MCP server listening on stdio for ${workspaceRoot}\n`);
这里卡我最久的点,不是修法,而是误判。
我前面一直往“协议实现有问题”那边查,结果根因只是我手贱打了句日志。
这类坑就很烦,很像那种“看起来挺高级,死因却非常接地气”的报错。

第二个坑更危险:我差点把“只读”做成“万能工具箱”
stdout 这个坑爬出来之后,我本来以为最恶心的已经过去了。
结果后面那下更要命,只是它没那么容易第一眼看出来。
我最早的方案是把一堆只读能力都做成 tool。
表面看很合理:
- 想看目录,调一个
- 想读文件,调一个
- 想搜代码,再调一个
可我自己连着调几次之后,越调越别扭。
AI 面前摆的是一排“可以主动调用的动作”。
可我真正想给它的,更像“你被允许看的上下文”。
这俩差别挺大。
前者像发工具。
后者像划观察区。
我就是在这儿突然反应过来,项目从根上就不该按“工具箱”来长。
所以我把入口说明也改成了另外一种思路。
不是让 AI 自己猜这台服务能干嘛,而是先把边界摊平:
export function buildServerInstructions(workspaceRoot: string): string {
const denyRules = getDenyRulesSummary();
return [
"This server exposes a private code workspace through read-only MCP resources.",
`Workspace root: ${workspaceRoot}`,
"Resources: repo://overview, repo://tree/{path}, repo://file/{path}.",
"Tool: search_code.",
`Denied directories: ${denyRules.directories.join(", ")}.`,
`Denied suffixes: ${denyRules.suffixes.join(", ")}.`,
].join("\n");
}
我挺喜欢这段的,不是因为它优雅,是因为它不装。
AI 一接进来,先知道自己在哪、能看哪、别碰哪。

然后我又顺手把那种会越写越危险的东西全砍了:
- 不给
run_shell - 不给写文件
- 不给改代码
- 不给任何远程 transport
砍完之后,项目倒顺眼了。
我当时还天真地觉得:防住 ../ 不就行了
这也是我自己给自己挖的坑。
前面我一直盯着路径逃逸。
我那阵子的思路很简单:把 ../ 堵死,把根目录钉住,事情应该就差不多了。
结果往下测的时候,我又被自己打脸。
因为不越界,不代表安全。
就算老老实实留在工作区里面,你照样可能把这些东西漏出去:
.git.env- 证书
- 私钥
也就是说,边界根本不是一层,是两层:
- 不能跑出工作区
- 留在工作区里也不是哪儿都能看
我前面就是把这两件事混成了一件事,才会觉得“已经防住了”。
后面我把它们拆开了。
先做路径归一化和根目录约束:
export function resolveWorkspacePath(workspaceRoot: string, relativePath = "."): string {
const normalizedRoot = path.resolve(workspaceRoot);
const resolvedPath = path.resolve(normalizedRoot, relativePath);
const relativeToRoot = path.relative(normalizedRoot, resolvedPath);
if (
relativeToRoot === ".." ||
relativeToRoot.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativeToRoot)
) {
throw new Error("Requested path must stay inside the configured workspace root.");
}
assertPathAllowed(resolvedPath);
return resolvedPath;
}

再单独拦掉敏感目录和敏感后缀:
const DENIED_DIRECTORIES = new Set([".git", "node_modules", "dist", "coverage"]);
const DENIED_SUFFIXES = [".env", ".pem", ".key", ".crt"];
这个拆法的好处很实在。
以后再看这段,我不会误以为“只要没越界就安全了”。
而且拒绝效果是能直接看到的:

目录和文件我故意拆成两步,不是为了优雅,是为了少翻车
我前面还犹豫过这个。
要省事的话,完全可以直接暴露读文件的能力。
甚至再粗暴一点,给个能一路读的 tool,项目立刻就能跑。
但我试了之后就发现,这样很容易乱。
AI 一上来就开始吞文件正文。
一旦读错了,你很难判断它到底是哪一步歪了。
后来我把它硬拆成两步:
tree只回答“这里有什么”file才回答“这个文件里面是什么”
别小看这个拆法,调试体验完全不一样。
很多问题会立刻清楚:
- 到底是路径找错了
- 还是文件本身被拒了
- 还是读内容的时候踩到限制了
文件资源出来的效果大概就是这样:

全文搜索我还是留了。
因为搜代码这件事确实更像一个动作,不太像静态资源。
但我只留这一把刀,多一把都不想给。
搜索结果长这样:

我把 smoke 跑完,心里才算踏实
我折腾这种小项目,到最后就认一件事。
别跟我讲一堆“设计上很安全”“理论上没问题”。
你就给我真跑。
所以我给这个项目留了两层验证。
第一层是自动化测试,专门盯这些地方:
- 路径越界会不会被拦
.env和.git/config会不会漏- 文本文件能不能读
- 目录树和搜索有没有实返

第二层是我更看重的 smoke。
这个脚本不是简单打印“启动成功”,而是真的把几个关键面走一遍:
- 读
repo://overview - 读
repo://tree/controllers - 读
repo://file/controllers/user-controller.ts - 调一次
search_code - 再去撞一次
.git/config
前四步过,最后一步被拒,我心里才算有底。

到这儿我才敢承认,这项目至少不是“文章写得挺稳,仓库一跑两码事”。
这题把我一个坏习惯改掉了
我前面老容易先想功能。
AI 要读代码?
那我先补工具。
AI 可能还想搜?
那我再补一个。
这次绕了几圈,我算是把顺序掰回来一点了。
先看边界。
再给上下文。
最后才考虑动作。
而且动作能少就少。
这个项目做到最后,留下来的东西不算多,甚至可以说有点抠。
但我现在就认这种抠。
后面我如果继续补这题,我第一眼不会去想“再加几个 tool”。
我会先盯两件事:
- 目录白名单能不能做得更贴近真实业务仓库
- 不同层级的资源暴露粒度是不是还该再收
再来一次的话,run_shell 这种东西我大概连想都不想了。
我会先看自己是不是又打算把“让 AI 帮我看代码”,偷偷做成了“给 AI 发钥匙”。
更多推荐

所有评论(0)