MCP Inspector 的 v2 核心连 package.json 都没有,npm workspace 也被它甩了
- v2 采用三端一核架构,核心 core/无 package.json,通过构建别名内联进各客户端,消除版本漂移
- 测试不写 mock,全用真服务器,通过预设配置复现每个修复的 bug,回归测试可重复执行
- --cli 模式让 CI 集成 MCP server 测试成为可能,支持输出断言和远程服务器探活
- 安全方面注意本地 HTTP 服务的 API token 嵌入 HTML、DNS rebinding 竞态、绑定地址默认 127.0.0.1 避免协议族问题
- 仓库缺少 LICENSE 文件,法律上悬空,使用需谨慎
大家好,我是若风。
写 MCP server 的人大概都经历过这样一个下午。改了一行工具定义,重启服务,跑到 Claude Desktop 里问一句,你能看到我的新工具吗。模型说看不到。你盯着终端发呆,分不清是自己的 server 没起对,是 stdio 管道断了,还是宿主缓存了旧的 tools/list。
这时候搜出来的答案十有八九是那句命令,npx @modelcontextprotocol/inspector。弹出一个网页,填上启动命令,点 Connect,工具列表出来了,参数表单出来了,请求历史也出来了。
然后你把它关掉,像用完一只一次性纸杯。
我一直觉得这事挺可惜。这个 10.7K Star 的官方仓库,多数人只用了它三成功力。7 月 28 日它发布了 2.0.0,整个 v2 是一次推倒重写,到今天已经迭代到 2.3.0。你算一下日子,7 月 28 日、8 月 5 日、8 月 12 日、8 月 19 日,四个版本全部落在周二,像上了发条。
说真的,我本来只想看看新版加了什么功能,结果在仓库里泡了一晚上。这篇文章聊聊这个调试器藏在网页面板底下的东西,包括一处它自己都没收拾干净的小坑。
一个二进制,三副面孔
先说 v2 最直观的变化。以前 Inspector 约等于一个网页,现在 @modelcontextprotocol/inspector 装出来的 mcp-inspector 一个入口,后面站着三个客户端。
| 命令 | 形态 | 底层 |
|---|---|---|
| npx @modelcontextprotocol/inspector | Web 界面,默认 | Vite + React + Mantine,带 Node 后端 |
| 追加 --cli | 纯命令行 | 脚本化输出,为 CI 和自动化设计 |
| 追加 --tui | 终端交互界面 | Ink,内核还是 React |
分发上有个细节挺讲究。launcher 把三个客户端拉起来的方式不是 spawn 子进程,而是在自己进程里直接跑,docs/launcher-config-consolidation-plan.md 专门写了一篇文档解释这个选择。你在 CI 里跑 --cli,不会多出一层进程,信号传递和退出码都干净。
功能面也补齐了 v1 时代的欠账。tools、resources、prompts 的调用历史,OAuth 全流程,分页拉取,MCP Apps 渲染,还有 2026-07-28 新协议版本的 Modern 模式。你把它当一个 MCP 宿主看,该干的活它都替你干了一遍,而且每一步的报文都摊开给你看。
核心是一个没有 package.json 的包
接下来是我觉得整个仓库最值得看的部分。
v2 不是 npm workspace。clients/ 下面 web、cli、tui 各自带 package.json 和 node_modules,共享代码放在根下的 core/ 目录。有意思的地方在于,core/ 自己没有 package.json,它压根不是一个包。
那三个客户端怎么消费它?答案是构建期别名。CLI 和 TUI 的 tsup.config.ts 里,esbuildOptions.alias 把 @inspector/core 映射到仓库的 core/ 目录,noExternal 再把它直接内联进 bundle。Web 端的 vite.config.ts 做同样的别名。发到 npm 的产物里,每个客户端都带着一份编译进去的 core。
整张图从上到下长这样。
核心里的重心是 core/mcp/inspectorClient.ts 的 InspectorClient 类,一个文件 6355 行,握着连接生命周期、请求响应、状态 store 和传输层。
你想想看,为什么要把事情做到这个地步。README 里写得很直白,v1 时代他们吃过亏。issue #1970 记录的事故是,依赖声明在多个客户端各写一份,结果 node_modules 里出现了两份 ext-apps,还拖着两份 v1 时代的 @modelcontextprotocol/sdk。两份 SDK 意味着类型可能对不上,行为可能对不上,bug 修了一份漏一份。所以 v2 定了规矩,MCP SDK 系列的包只允许出现在根 package.json,靠 Node 的目录向上查找让所有客户端共享,一份就是一份。
这种「依赖写在哪」的较真还延伸出一些反直觉的决定。比如 vite 和 @vitejs/plugin-react 摆在根的 dependencies 里,乍看像构建工具放错了地方。其实不是,clients/web/server/start-vite-dev-server.ts 在跑 --web --dev 时会真的 import 它们,发布后的 tarball 要靠根 manifest 解析依赖。挪去 devDependencies 的话,本地测试全绿,用户 npx 就炸。README 说这也是故意的,npm audit --omit=dev 里能看到它们,因为它们确实活在生产树里。
一个仓库,一个版本号,一次发布。
整个仓库只有根 package.json 有 version 字段,三个客户端一个版本号都没有。连发布流程都为这个设计服务。版本号先在 v2/main 开发分支上 bump,随里程碑合进 main,然后才打 tag,而且必须 tag origin/main 而不是本地 HEAD,tag 名不带 v 前缀。README 专门写明 npm version 必须加 --no-git-tag-version,还管这个参数叫 load-bearing,承重墙。少了它,标签会打在一个永远不发布的提交上。这套流程不是凭空发明的,#2010 记录了之前版本号 bump 位置不对导致 v2/main 在 2.0.0 上卡了整整两个发布周期的教训。
core 里最能体现「共享」价值的例子,是 core/json/nullableUnion.ts 的 normalizeNullableUnion。Zod 的 .nullish() 会把「可选且可为空」的字段编译成 anyOf 带一个 null 分支的 JSON Schema,真正的类型信息挂在分支上。Web 的 SchemaForm 和 TUI 的 schemaToForm 都按顶层 type 字符串分发表单控件,遇到这种结构会整个掉进裸 JSON 编辑框。v2.2 之前你在表单里改一个可空字段,每次击键它都会把自己的内容再转义一遍,直到值没法用,这就是 issue #1928。修复方式是把「折叠可空联合类型」这一步抽成 core 里的公共函数,两个表单构建器都调它。README 的原话是 precisely so they cannot drift,就是为了让它们没法漂移。
这段值得单独品味。三端一核的架构谁都会喊,真正防住漂移的不是口号,是把每一个容易各写一份的逻辑都摁进同一个文件。
测试不写 mock,全上真服务器
第二个让我停下来看了很久的,是 test-servers/ 目录。
它的思路一句话能说清,集成测试不 mock,起真的 MCP server,走真的 transport。server 从 preset 组装,test-servers/src/preset-registry.ts 是一批 fixture 工厂,createTestServerHttp、createEchoTool、mrtr_confirm 这些。要一台带 OAuth 的服务器,加一个带分页的,加一个会返回空结果的,拼一份 JSON 配置就行。跑法有两种,HTTP 集成测试直接在测试进程的事件循环里 import 工厂函数,stdio 路径则把构建产物 test-server-stdio.js 当真的子进程 spawn。
更有意思的是 showcase configs。test-servers/configs/ 下躺着一排 JSON,每个对应一个功能点和至少一个 issue。比如 duplicate-tool-names-http.json 复现 #1957,让一个 tools/list 里出现重复的工具名。SDK 的 registerTool 会拒绝重名,预设根本造不出这种形状,所以他们专门写了配置,在列表尾部重复追加两个同名工具。为什么放尾部不放紧挨着原位?因为 React 对连续同 key 的子元素先做头部匹配,挨着放恰好对得上,缺陷就藏住了,分开摆才能稳定复现。而且这才是真实世界里的样子,两个工具来源拼在一起。
再比如 mrtr_loop 这个 preset,一个永不完成的工具调用,专门用来触发 MRTR_MAX_ROUNDS 的轮数上限,这个值在 inspectorClient.ts 里定死为 10。modern-network-http.json 里还有四个 trigger_* 工具,分别回应 400 加 -32020、404 加 -32601 这些协议规范定义的错误组合,让 Network tab 把每种错误渲染成不同的样子。
这种做法等于把每个修过的 bug 变成一台可以随时重放的服务器。回归测试不再依赖一段「复现步骤」,依赖的是一条命令。
Web 端的组件测试也走了类似的路线。v2 的组件全是 dumb components,只接收数据和回调,状态全部来自 core 的 hooks。所以每个组件都能配 *.stories.tsx,仓库里有 96 个以上的 story,Storybook 的 play functions 在 CI 里用 headless Chromium 兼做交互测试,对应 npm run ci:storybook。
讲真,这套组合对做客户端工具的团队是很值得抄的作业。测试金字塔的底端不用假对象,用真进程。
一台会 spawn 进程的本地服务,安全账怎么算
坦白讲,我一开始没把「调试器的安全」当回事。翻完 issue 区和几个安全相关的文件后,想法变了。
这个工具的本体是一个本地 HTTP 服务。stdio 模式下你让它跑什么命令它就跑什么,还带一个 fetch 代理。GET / 返回的 HTML 里嵌着 API token,这是 clients/web/server/inject-auth-token.ts 干的活。而一个不带 Origin 头的请求可以跳过来源白名单。README 在 Docker 一节反复叮嘱端口映射要绑回环地址,别用裸的 -p 6274:6274,理由就是这个,发布到所有网卡等于把它挂进你的局域网,而对非浏览器客户端来说,API token 是最后一道闸。
issue 区还躺着一个没合并的安全修复,#1732,DNS rebinding 的 TOCTOU 竞态。攻击路径写得清清楚楚。safeProxyFetch 先用 assertSafeProxyTarget 校验目标域名,DNS 解析出来是 1.2.3.4,过了黑名单检查,然后 node-fetch 自己再解析一次域名,攻击者在这中间把短 TTL 的 DNS 切到 169.254.169.254,也就是云厂商的实例元数据地址,拿到 IAM 凭证再原路返回。两次独立的 DNS 解析,中间那段时间就是攻击窗口。
修复方案也很硬核。assertSafeProxyTarget 改成返回校验过的 IP 列表,新的 createPinnedAgent 造一个 lookup 钩子被钉死的 agent,fetch 永远连那个 IP,第二次 DNS 解析压根不发生。
还有一个更隐蔽的坑藏在绑定地址里。clients/web/server/resolve-bind-host.ts 的策略是默认绑定 127.0.0.1 这个地址,而不是 localhost 这个名字。因为在 glibc 的 Linux 上,listen(localhost) 可能只解析到 ::1 一个协议族,所有 IPv4 客户端直接被拒之门外,issue #1951 记录的就是这个。一个本地调试器,光是把「绑在哪」这件事做对,就踩了这么多层。
拿 CLI 进 CI,这是 v2 最实用的增量
说回大家最关心的实用价值。--cli 模式让「给 MCP server 写回归」变成一条 shell 命令的事。
# 列出全部工具
npx @modelcontextprotocol/inspector --cli node build/index.js --method tools/list
# 调用某个工具
npx @modelcontextprotocol/inspector --cli node build/index.js \
--method tools/call --tool-name mytool --tool-arg key=value
# 探一台远程服务器
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com \
--transport http --method tools/list --header "X-API-Key: xxx"
在 CI 里对输出做断言,新加的工具没漏注册、调用返回的结构没变形,都能挡在合并之前。远程服务器加自定义 header、走 OAuth、配 roots 都支持,细节写进 --config 文件可以复用,文件里的配置和命令行参数谁覆盖谁也有明确规则。
不过也别把它当银弹。#2028 里 FastMCP 用户踩的坑就挺典型,工具里一调 ctx.info 或 ctx.report_progress,v2.2.0 在 LEGACY/STDIO 模式下直接超时。还有 #1936,连 https://localhost 开头的地址会失败。v2 满打满算四周大,这类边角 bug 的密度就是会高一些,好在 issue 区很活跃,欠账在快速消化。
对了,还有一个我必须说的坑。README 结尾写着 License MIT,package.json 的 license 字段也是 MIT,但我翻了根目录的文件树,里面没有 LICENSE 文件,GitHub 的 license API 返回 404。严格讲,一个没有许可证文本的仓库,默认状态是版权所有,对想抄一段代码进自己项目的人来说是个法律上的悬空态。对一个官方组织名下的仓库,这更像 v2 搬家时的疏漏,但坑是真实存在的,用之前值得掂量一下。
横向比一下同类选择。Postman 现在也支持 MCP 调试,图形化体验好,适合偶尔手动戳一戳。Claude Desktop 的日志文件是最原始的手段,信息窄还难搜。Inspector 的差异化在于它是官方协议参考实现级别的客户端,协议新特性都在这里最先落地,加上 CLI 可编程,这是前两者给不了的。
把共享编译进每一个消费者
回头看,v2 最值得带走的是一个可以起名字的模式,构建期共享。
一个产品要多端发布,多数团队的默认答案是 npm workspace,或者往私有 registry 发共享包。Inspector 选了更激进的一条,共享代码不成为包,通过构建别名内联进每一个消费者。代价是第三方没法直接复用 core,他们明确把发布 core 为独立包这件事推迟了,issue #1636 里讨论着。收益是把版本漂移从物理上消灭,两份 SDK 的故事在这里不会再发生。
什么时候值得抄这个模式?我的判断是,共享代码只服务于自家几个端、不需要对外暴露 API、而且被多端一致性折磨过的团队,构建期内联比 workspace 更狠也更省心。反过来,如果共享层要给外部用,或者各端发版节奏差异大,老老实实发包。
至于这个项目本身,写 MCP server 的人把它加进工作流几乎没有犹豫的理由。网页调试用默认模式,回归测试把 --cli 挂进 CI,排查远程服务器翻 Network tab。四周边龄,周更节奏,官方血统,剩下的那些坑,issue 区会一条条填上。

评论互动