验证¶
验证基线:18aa9af2b2c26fd114113dd39528a91224801840(从 main 拉取)。
工作树已移动至该基线上,本 slice 的更改保持未提交状态,然后使用 npm ci 重新安装(exit 0;新增 540 个包,审计 541 个;耗时 6.56 秒,用户态 3.85 秒,系统态 8.07 秒,最大常驻内存 881459200 字节)。本轮编辑完成后,使用 npm run build 重建了工作树(exit 0,三个 turbo 任务;耗时 5.23 秒,用户态 13.94 秒,系统态 2.93 秒,最大常驻内存 2964652032 字节),并运行了 npm run test:prepare(exit 0,六个任务,其中三个命中缓存;耗时 1.37 秒,用户态 3.07 秒,系统态 0.76 秒,最大常驻内存 422625280 字节);dist/mcp/ 在 main.js 旁带有重建后的 serveStreamableHttp-*.js chunk。
最终工作树的静态门禁:npm run check:fast 通过(类型检查和 lint,两个任务;耗时 4.69 秒,用户态 24.25 秒,系统态 3.00 秒,最大常驻内存 4480139264 字节)。npx oxlint 对本轮改动的文件未报告任何发现。最后一次编辑(包含本记录)后,npm run format 运行于 4032 个文件(耗时 0.68 秒,用户态 4.11 秒,系统态 0.74 秒,最大常驻内存 227393536 字节),且 npm run format:check 通过(耗时 0.69 秒,用户态 4.09 秒,系统态 0.62 秒,最大常驻内存 216989696 字节)。npx knip 退出码 0(耗时 3.80 秒,用户态 5.10 秒,系统态 0.72 秒,最大常驻内存 868679680 字节)。npm run check:ci:tasks 全部八个任务通过,其中七个是同一源上运行的 turbo 缓存命中(耗时 0.88 秒,用户态 4.52 秒,系统态 1.04 秒,最大常驻内存 215171072 字节):jscpd 报告 3143 个文件中有 2576 处克隆和 3.35% 的重复行,低于 3.5% 阈值;lint:built 在 3734 个文件上 0 错误;format:check 全新运行。npm run docs:check 通过,包括 README 一致性测试(5 个用例;耗时 3.27 秒,用户态 3.43 秒,系统态 0.80 秒,最大常驻内存 288866304 字节)。git diff --stat 列出 13 个被跟踪文件,均不在 src/domain/ 下。
源组测试:/usr/bin/time -l npx vitest run --project adapters src/server/http src/server/shutdownRegistration.test.ts 通过 3 个文件、17 个测试,耗时 2.74 秒,用户态 3.12 秒,系统态 0.93 秒,最大常驻内存 289767424 字节。
serveStreamableHttp.test.ts 新增了并发用例:一个带有一个打开会话的 slot 通过 Promise.all 接收两个 initialize 请求;恰好一个返回带会话 id 的 200,另一个返回 400;该实例的 onclose 钩子已触发零次;slot.close() 触发一次。在修复前对同一文件运行时,该用例失败,钩子计数为 0 而非 1(1 失败,5 通过;耗时 1.07 秒,用户态 0.84 秒,系统态 0.21 秒,最大常驻内存 158187520 字节),这正是评审所描述的固定为 no-op 的钩子。
shutdownRegistration.test.ts 断言:registerProcessShutdown 按名称将 SIGINT/SIGTERM 交给处理器,并在 stdin 结束时传 undefined;省略 stdin 时不注册输入监听器;registerSignalShutdown 报告 143/130。
构建组测试在 npm run test:prepare 之后运行。定向的 dispatcher 运行,/usr/bin/time -l npx vitest run --project cli-boundary tests/boundary/cli/dispatcher.test.ts,通过 3 个测试,耗时 1.08 秒,用户态 0.89 秒,系统态 0.26 秒,最大常驻内存 153026560 字节,现在包括 mcp add --http 路由到 CLI,而 mcp --http --port 0、--mcp --http 和 mcp --host 127.0.0.1 --http 路由到 HTTP 入口,mcp doctor --http、mcp add 和 --mcp extra 保持原有路由。
四个完整项目按顺序运行,同时一个同级 worktree 在同一主机上运行它自己的 cli-boundary、mcp-boundary 和 acceptance 链。
/usr/bin/time -l npx vitest run --project adapters 通过 247 个文件、3945 个测试(跳过 8 个),耗时 93.77 秒,用户态 80.21 秒,系统态 30.69 秒,最大常驻内存 344260608 字节。/usr/bin/time -l npx vitest run --project mcp-boundary 通过 155 个文件(跳过 2 个)、910 个测试(跳过 7 个),耗时 167.51 秒,用户态 321.24 秒,系统态 51.30 秒,最大常驻内存 4923490304 字节(包括 Vitest 父进程、其 worker 以及派生的 CLI 子进程)。/usr/bin/time -l npx vitest run --project process-boundary 退出码 1:56 个文件通过,2 个跳过,2 个失败;460 个测试通过,19 个跳过,7 个失败;耗时 544.81 秒,用户态 160.89 秒,系统态 133.49 秒,最大常驻内存 1028931584 字节。/usr/bin/time -l npx vitest run --project cli-boundary 退出码 1:108 个文件通过,3 个失败;658 个测试通过,2 个跳过,7 个失败;耗时 452.34 秒,用户态 734.29 秒,系统态 234.02 秒,最大常驻内存 648495104 字节。没有失败文件属于本 slice。
失败分为两类。负载类:六个 jadxIntegration.test.ts 用例触发了 30 秒测试或 10 秒钩子超时;六个 javascriptApplication*.test.ts 用例因 worker 的结果容量限制而失败。在其他链结束后,于本 worktree 中单独重跑,jadxIntegration.test.ts 全部 48 个测试通过,两个 JavaScript 文件全部 20 个测试通过(cli-boundary 三个文件:20 通过,1 失败,耗时 23.38 秒,用户态 32.99 秒,系统态 10.97 秒,最大常驻内存 343244800 字节;process-boundary 两个文件:48 通过,1 失败,耗时 62.48 秒,用户态 21.89 秒,系统态 17.55 秒,最大常驻内存 686620672 字节)。此环境中 main 上已存在的问题:closedStdout.test.ts 的“在流式 JSON 期间读取器关闭时安静停止”用例退出 1 而非 141,收到 1009 字节且 stderr 为空;releaseArtifactCanary.test.ts 解析 npm pack --json 失败,npm 12.0.2 将其输出为以包名为键的对象,而不是测试所期望的元组。两者在 52b73ee857029a3ab8f5c6eeef233b295951a46c 处的干净同级克隆上都能以相同方式复现(无已跟踪或未跟踪的更改;closedStdout 1 失败,3 通过,耗时 8.86 秒,用户态 6.98 秒,系统态 2.29 秒,最大常驻内存 348012544 字节;canary 1 失败,耗时 7.91 秒,用户态 7.76 秒,系统态 1.84 秒,最大常驻内存 668729344 字节)。该克隆比验证基线早 15 个提交,其中没有提交触及这两个测试或 scripts/rea.mjs。由于 dispatcher 位于 closedStdout 路径上,因此也针对本 worktree 构建的 dist/ 重放了其场景,分别使用 18aa9af2 的 scripts/rea.mjs 和编辑后的版本,各运行三次:全部六次均以 1 退出,收到 1009 字节且 stderr 为空。本轮未针对这些失败做任何更改。
跨实现检查:使用 Python mcp 1.30.0 客户端(httpx 0.28.1,Python 3.12.12)针对构建好的 node scripts/rea.mjs mcp --http --port 0 < /dev/null:stderr 宣告了 {"transport":"streamable-http","url":"http://127.0.0.1:57093/mcp","msg":"MCP transport listening"}。会话 A:initialize POST 返回 200 text/event-stream,带 mcp-session-id;notifications/initialized 返回 202 且无正文;独立的 GET 以及 tools/list 和 tools/call POST 均返回 200 text/event-stream;tools/list 报告 207 个工具,包括 binary_session;使用 {} 调用 binary_session 返回 isError=False;DELETE /mcp 返回 200。会话 B 在该 DELETE 之后打开,使用新的会话 id 初始化,列出了同样的 207 个工具,binary_session 再次返回 isError=False,随后其自身的 DELETE 返回 200。服务器记录了两行 MCP HTTP session opened 和两行 MCP HTTP session closed by the client,随后 SIGTERM 使其以状态 143 退出,stdout 保持为空。会话 A 的方法、状态码和会话 id 处理与记录的参考交换逐步一致;参考的 echo 服务器将其 202 和 DELETE 响应标记为 application/json,而此监听器对这些无正文响应不发送内容类型。
独立审查¶
对首次实现的一次对抗性审查复现了以下问题:在 DELETE 之后,下一个会话能够初始化并列示工具,但每次工具调用都会因 cancelled 错误而失败,原因是 SDK 的 DELETE 处理关闭了传输通道,而服务器的 onclose 钩子执行了提供方的拆除逻辑。该审查还发现:会话在未发送 DELETE 的情况下结束会阻塞监听器直到重启;绑定失败日志行缺少系统原因;存在重复的外部 Host 探测;rea mcp --http --help 缺少末尾换行;以及在 stdio 路径上静态导入了 HTTP 监听器。每项发现都已在本文档中通过适配器、mcp-boundary 和 Python 检查得到处理和覆盖。
对变基后的代码树进行的重新审查提出了四点,均已在本文解决。两个并发的 initialize POST 可能会留下已安装的 no-op onclose 钩子,因为每次会话结束时都捕获了该时刻当前的钩子,而较晚的 finally 恢复的是另一次调用的 no-op;McpSessionSlot 现在在工厂返回完整包装后的服务器之后,只捕获一次实例的钩子,并且每次会话结束都精确恢复该钩子;上述适配器用例在此改动前失败、改动后通过。registerProcessShutdown 的可选 stdin 参数在生产环境中是死代码;registerSignalShutdown 现在是 registerProcessShutdown(handler, { signals }) 的一个投影,因此由同一个原语负责注册,而处理程序会获知信号并保留 130/143。调度器此前会将任何包含 --http 的 mcp 调用路由到 HTTP 入口;现在只有在 mcp 或 --mcp 之后的第一个 token 是标志(flag)时才会这样做,因此 rea mcp add --http 会保留 CLI 自身的诊断信息,调度器测试覆盖了该情况。早期记录中提到的 src/domain/propertyListXml.test.ts 中的格式化差异块在变基后已消失,git diff --stat 证实了这一点。替换规则保持原文档所述不变,而 docs/mcp-contracts.md 现在用一句话说明该规则及其理由,以便维护者自行决定。
范围与测量限制¶
并发 initialize 场景在插槽(slot)中使用内存中的 Request 对象进行测试,两个调用步调一致地推进;在 TCP 上,两个连接在不同的事件循环轮次中处理,因此该竞争仅在进程内可达,监听器层面的行为仍然是“最新客户端拥有服务器,另一个客户端看到 400 或 404”。非环回(non-loopback)放宽路径仅通过纯 allowlist 辅助函数和环回冒烟测试覆盖;未实际进行宽泛绑定(wide bind)测试。会话一次只服务一个;不支持并发客户端。在干净克隆上可复现的两个失败属于环境问题(macOS + Node 22.23.1 + npm 12.0.2),留给其所有者处理;由负载引起的超时是通过隔离的重复运行分类的,而不是在空闲主机上重复运行整个项目。标识绑定原因的错误级日志行在更早的运行中观察到,但 mainErrors.test.ts 并未对其断言。响应预算保持了源自 stdio 的下限,且未在 HTTP 上重新测量。未在 Windows 上运行测试。
针对 PR 的自动化审查发现(2026-10-18)¶
Codex 和 Greptile 在 morluto/rea#4955 的 46dd8f0e 提交上留下了 8 条行内审查发现;每一条都已对照代码核查,并在一个后续提交中处理,且在行为可观察处添加了回归测试:
- 一个迟到的
initialize可能在关闭后启动一个新的 transport(Codex, P1):插槽现在会记录自身已关闭,close()会在结束会话前先加入一个正在启动的会话,之后每个请求都会被拒绝并返回503。测试:一个与close()竞争的 initialize 会得到503,关闭钩子只触发一次,下一个请求也会得到503。 - 失败的
close()会被永久缓存(两位审查者均标记为 P1):close 的三个阶段各自只运行一次,失败阶段由下一次调用重试。测试:实例的第一次close()会拒绝(reject),第二次调用会成功并释放端口。 - 环回别名跳过了 Host 校验(两位审查者分别标记为 P2 和 P1):绑定现在按地址类别判定为环回(
127.0.0.0/8、::1、IPv4 映射的环回地址、localhost名称),并采用与 SDK 应用于 Host 头相同的规范化处理;allowlist 会加入绑定自身的 hostname,因此对127.0.0.2或localhost.的请求仍会被服务。测试:十二种拼写。 - 一个无会话(sessionless)POST 可能在任何 header 检查之前关闭活动会话(Greptile, P1):在环回绑定上,路由使用与
Host相同的 allowlist 校验Origin;只有携带Content-Type: application/json且Accept同时包含application/json与text/event-stream的 POST 才可以替换会话;其他请求会到达活动 transport,并被其拒绝。测试:一个text/plain的 initialize 和一个带有外部Origin的 initialize 会被拒绝(分别为 4xx 和 403),同时已连接的客户端仍能继续 echo。 - 绑定 URL 在较安静的日志级别下消失了(Greptile, P2):监听日志行通过一个固定在
info级别的子 logger 写出。测试:warnlogger 仍会收到包含 URL 的那一行。 rea mcp -h --http进入了普通 CLI(Greptile, P2):调度器谓词接受入口之后的任何 flag。测试:调度器用例。
改动后的检查:oxfmt --check . 干净通过;tsc --noEmit 干净通过;对涉及到的源码运行 oxlint,在把分阶段 close 提取为独立函数后干净通过;npx knip 无发现;npm run jscpd 重复行 3.35 %;scan:todos 无;verify:test-discovery:1,583 个文件均只被发现一次;针对监听器和 bridge 适配器测试运行 vitest run,2 个文件中 14 项测试通过;在 npm run build:cached 之后,针对调度器、option、streamable HTTP、shutdown 和 main-error 边界测试运行 vitest run,5 个文件中 22 项测试通过。
对修复的第二轮自动化审查(Codex,两条 P2 发现)已在下一个提交中处理:替换守卫现在比较的是媒体类型本身(media type proper),因此 application/json-patch+json 不再被视为 JSON(测试:此类 initialize 会被拒绝,同时已连接的客户端继续 echo);服务器实例只在监听器完成绑定之后才创建,因此绑定失败时无需清理任何东西,而工厂函数失败时会释放监听器并重新抛出异常(测试:一个会抛错的工厂函数会 reject,且不会宣告 URL)。
第三轮(Greptile 和 Codex,共五项发现)随后得到处理:监听器将 error 转发到 onerror 的动作是在 bind 之后才注册的,因此 EADDRINUSE 只会打印监听失败消息(测试:被占用的端口拒绝连接时不会调用 onerror);带作用域的 IPv6 字面量会在绑定前被拒绝,无法根据已绑定地址构造出 URL 时会关闭监听器(测试:fe80::1%lo0 在工厂运行前就被拒绝);回环判断现在基于已绑定地址,并将配置的名称加入允许列表(测试:ip6-localhost 绑定到 ::1,dev.local 绑定到 127.0.0.1);MCP runtime 会传递 closeInstance: false,因此 startMcpTransport 中的工厂所有者仍然是实例清理的唯一所有者(测试:close 会让实例保持打开,直到其所有者将其关闭)。
第四轮(Codex,共两项发现)随后得到处理:替换守卫将 Accept 解析为媒体范围,并要求 application/json 和 text/event-stream 条目精确匹配(测试:application/json-patch+json, text/event-streaming 会被拒绝,而已连接的客户端持续回显);传输关闭被拒绝的会话保持被拥有状态,因此下一次关闭会重试(测试:一次失败的传输关闭会让 close() 被拒绝一次,第二次调用完成,关闭钩子只触发一次,后续请求收到 503)。
第五轮(Codex,共三项发现)随后得到处理:实例的关闭钩子现在恰好运行一次,即使监听器在没有活动会话的情况下关闭也不例外(测试:对空闲槽位关闭两次只触发一次;空闲监听器关闭也只触发一次);initialize 探测不再等待取消其克隆的请求体——该克隆体是 tee 的一个分支,取消它会等待原始请求排空(测试:4.5 MB 的无会话 initialize 会被立即拒绝,而已连接客户端持续回显);node:http 桥接现在将重复的 Set-Cookie 值作为数组传递,而不是把它们合并(测试:由组合路由返回的两个 cookie 都能到达)。
第六轮(Codex,一个 P1)随后得到处理:信号处理程序原本只在异步启动返回之后才安装,因此,在打开目标、加载 provider 或绑定 HTTP 期间收到 SIGINT 或 SIGTERM,会在没有清理的情况下结束进程。HTTP 入口现在会在启动前锁存信号;生命周期所有者一注册就会拾取所记录的信号;启动期间收到第二个信号则立即退出(测试:锁存记录 143,在第二个信号时退出,在释放时移交状态,并且不留下任何监听器)。
两位评审者随后指出,锁存信号的状态会被入口正常的 0 返回覆盖:HTTP 入口现在通过 createExitStatus 管理自己的退出状态,其中,记录了信号后的 0 会保留信号状态,而真正的失败仍然优先(单元测试);进程测试会在监听行出现的那一刻发送信号,并断言信号状态。
第八项发现(Codex):在 MCP 处理器读取请求体之前发送回复,会使该请求体在暂停的桥接流上未被读取,因此慢速上传者可能占用 socket。桥接现在会在任何请求体从未被读取的回复之后关闭连接(测试:未知路由的 POST 和外部 Host 的 POST 都会以 Connection: close 回应,而没有请求体的 keep-alive GET 则保持连接)。
第九轮(Greptile 和 Codex):请求转换原本在守卫之外运行,因此 Fetch 拒绝的方法(TRACE)可能会使进程崩溃;现在转换受到守卫保护,并以 405 和关闭连接来响应(测试:TRACE 收到 405,下一个客户端仍能回显)。未读请求体的判定现在来自传入消息自身的请求体声明,因此带有请求体的 GET 或 HEAD 也会关闭连接;回归测试使用 keep-alive agent,并检查 socket 在提前回复后确实关闭,而无请求体的 GET 则保持其 socket。
第十轮(Codex,共三项发现):接管守卫现在要求单独的 initialize,与传输层保持一致(测试:initialize 加上 notifications/initialized 的批量请求会被拒绝,而已连接客户端持续回显);关闭钩子只有在其返回之后才记录完成,因此抛出异常的钩子在下一次关闭时仍然符合条件(测试:抛异常一次的钩子会在两次关闭中运行两次,之后不再运行);桥接将请求目标按原样拼接到监听器的 origin 上,并拒绝绝对形式(absolute-form)的目标(测试://attacker.example/card 对于该路径是 404;普通路由只会看到绑定的主机;http://attacker.example/card 返回 400)。
第十一项发现(Codex):URL 构造会对点号段(dot segments)进行规范化,因此 /public/../mcp 会到达 /mcp。桥接现在拒绝任何在 URL 解析后路径发生改变的目标,因此路由始终看到客户端发送的路径(测试:/public/../mcp、/public/%2e%2e/mcp 和 /./mcp 会得到 400;/card?x=1 仍然可以路由)。
第十二项发现(Codex):bodyUsed 表示“已扰动”,而不是“已消费”,因此组合路由只读取上传内容的一个分块,却会让其余部分在 keep-alive socket 上保持暂停。关闭决策现在依据传入消息的流结束状态(测试:只读取 256 KiB 上传内容一个分块的路由,会得到 Connection: close 和已关闭的 socket;读取全部内容的路由则保持连接)。
第十三轮(Codex):带 q=0 的 Accept 范围不再计入接管守卫(测试:application/json;q=0, text/event-stream 会被拒绝,而已连接客户端持续回显)。同一轮还指出,启动失败会把已打开的会话和 provider 的清理留给一次尝试;该路径是原有的 stdio 启动失败清理逻辑,HTTP 模式现在在 EADDRINUSE 时会走到这一路径;是否处理由维护者决定。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 rea2/rea