这事是我前几天顺手折腾的一个小项目。

不是做 Agent,不是接模型接口,也不是折腾什么花活 UI。
我就想做个本地 MCP 服务,让 AI 能看我机器上一份私有业务代码。

要求听着很朴素:

  • 能知道项目目录长什么样
  • 能读指定文件
  • 能搜代码
  • 但别给我乱跑 shell
  • 也别顺手把 .env.git/config 这种东西翻出来

我一开始真觉得这题不大。
起个 Node 服务,挂个 stdio,塞几个工具,不就完了。

结果一上手就开始拐弯。

先是服务明明启动了,协议却被我自己打脏了。
然后我又差点把“只读上下文”写成“给 AI 发一套远程排障工具箱”。

这俩坑,一个土,一个危险。
也是被这两下敲了,我才把这个项目从“看起来挺能干”收成了“边界还算老实”。

这题我一开始就想歪了

刚动手的时候,我脑子里冒出来的全是工具名:

  • read_file
  • list_dir
  • search_code
  • git_diff

再往后一点,我甚至都快写出 run_shell 了。

那会儿我完全没觉得哪儿不对。
AI 缺什么,我就补什么,像给同事配一套远程排查工具。

写着写着我才开始犯嘀咕。

我明明想做的是“让 AI 读私有代码”,怎么味儿慢慢往“给 AI 更大的主动权”那边滑了。
尤其一想到 run_shell 真接进去,我自己都开始冒冷汗。

我当时一直以为问题在能力不够。
翻来翻去才反应过来,不是能力,是边界先歪了。

这个题先该问的不是“AI 还能多干点什么”,而是“我到底只想让它看到什么”。

想明白这句之后,整个项目我就开始收。

收完之后,我只留了四个面:

  • repo://overview
  • repo://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

这个脚本不是简单打印“启动成功”,而是真的把几个关键面走一遍:

  1. repo://overview
  2. repo://tree/controllers
  3. repo://file/controllers/user-controller.ts
  4. 调一次 search_code
  5. 再去撞一次 .git/config

前四步过,最后一步被拒,我心里才算有底。

在这里插入图片描述

到这儿我才敢承认,这项目至少不是“文章写得挺稳,仓库一跑两码事”。

这题把我一个坏习惯改掉了

我前面老容易先想功能。

AI 要读代码?
那我先补工具。

AI 可能还想搜?
那我再补一个。

这次绕了几圈,我算是把顺序掰回来一点了。

先看边界。
再给上下文。
最后才考虑动作。

而且动作能少就少。

这个项目做到最后,留下来的东西不算多,甚至可以说有点抠。
但我现在就认这种抠。

后面我如果继续补这题,我第一眼不会去想“再加几个 tool”。
我会先盯两件事:

  • 目录白名单能不能做得更贴近真实业务仓库
  • 不同层级的资源暴露粒度是不是还该再收

再来一次的话,run_shell 这种东西我大概连想都不想了。
我会先看自己是不是又打算把“让 AI 帮我看代码”,偷偷做成了“给 AI 发钥匙”。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐