给 Cursor CLI 的 MCP 超时动手术:26 行代码解决 60 秒断连
用 Relay 做 Cursor Agent 的人机回环时,遇到一个让人抓狂的问题:Agent 调用 relay_interactive_feedback 等待我输入回复,我还没打完字,连接就断了——MCP error -32001: Request timed out。
60 秒。MCP SDK 写死了一个 60 秒的请求超时,对于需要人工交互的工具来说完全不够用。有时候需要查资料、截图、写一段描述,60 秒眨眼就过了。
定位问题
Cursor CLI 的核心是一个 webpack 打包的 index.js,代码量巨大且完全压缩。没有 source map、没有人类可读的函数名、变量全部混淆。要在这种代码里找到超时逻辑,只能靠搜索。
先搜错误信息:
rg "Request timed out" index.js找到了。然后向前搜 6e4(60000 的科学计数法)结合 _setupTimeout:
rg -o ".{0,200}_setupTimeout.{0,200}" index.js关键代码段(格式化后):
const A = (m = n?.timeout) !== null && m !== void 0 ? m : 6e4;
this._setupTimeout(f, A, n?.maxTotalTimeout,
() => g(new bt(re.RequestTimeout, "Request timed out", { timeout: A })),
n?.resetTimeoutOnProgress ?? false
);翻译成人话:从请求选项中取 timeout,如果没传就用默认值 6e4(60000ms = 60s),然后设置超时定时器。
接下来追调用链。McpSdkClient.callTool 里调用了 this.client.callTool():
rg -o ".{0,100}McpSdkClient\.callTool.{0,500}" index.js发现 callTool 调用 SDK 的 request() 方法时没有传任何 timeout 选项。也就是说所有 MCP 工具调用都用的 60 秒默认值,没给外部任何配置入口。
问题根因清楚了:MCP SDK 写死了 60 秒默认超时,Cursor 的 McpSdkClient 调用时没有覆盖这个值,也没有暴露配置方式。
方案选型
代码是打包过的,没法直接改源码然后重新编译。想了几个方向:
| 方案 | 可行性 | 问题 |
|---|---|---|
| 猴子补丁 Protocol 类原型 | 理论可行 | webpack 打包后类名被混淆,找不到原型 |
直接修改 index.js 的 6e4 | 简单 | 6e4 在文件中出现了 22 次,只有 1 处是目标,硬改容易误伤 |
--require 预加载脚本 hook Module._compile | 可行 | 在模块编译时做正则替换,精准定位 |
改 Node.js 的 setTimeout 全局函数 | 可行 | 太暴力,影响所有定时器 |
选了第三个。Module._compile 是 Node.js 模块系统的底层方法——每个 .js 文件被 require 时都会经过它。在这里拦截,可以在代码被 V8 执行之前修改源文本,相当于一个编译期补丁。
实现
最终的 hook 只有 26 行:
'use strict';
const Module = require('module');
const origCompile = Module.prototype._compile;
const timeoutMs = parseInt(process.env.MCP_REQUEST_TIMEOUT_MS, 10) || 600000;
Module.prototype._compile = function (content, filename) {
if (content.includes('_setupTimeout') && content.includes('Request timed out')) {
content = content.replace(
/void 0!==(\w)\?\1:6e4;this\._setupTimeout/g,
`void 0!==$1?$1:${timeoutMs};this._setupTimeout`
);
}
return origCompile.call(this, content, filename);
};几个关键设计点:
两层守卫条件。先用 includes 检查文件是否包含 _setupTimeout 和 Request timed out,只有两个字符串同时存在才执行替换。这避免了对无关模块做正则匹配的性能损耗——整个 index.js 有几万行压缩代码,正则不便宜。
正则只匹配完整模式。/void 0!==(\w)\?\1:6e4;this\._setupTimeout/g 不是简单地把所有 6e4 替换掉,而是匹配 void 0!==x?x:6e4;this._setupTimeout 这个完整的三元表达式。index.js 中有 22 处 6e4,只有 1 处紧挨着 _setupTimeout——正则保证了零误伤。
环境变量可配置。默认值 600000(10 分钟),可以通过 MCP_REQUEST_TIMEOUT_MS 环境变量覆盖。传入的超时参数(n?.timeout)仍然优先——hook 只改了 fallback 默认值。
集成
hook 需要在 index.js 加载之前生效,所以用 Node.js 的 --require 机制注入。改动两个文件:
agent.cmd(CLI 入口):
@echo off
set "CURSOR_INVOKED_AS=agent.cmd"
if not defined NODE_COMPILE_CACHE set "NODE_COMPILE_CACHE=%LOCALAPPDATA%\cursor-compile-cache"
"%~dp0node.exe" --require "%~dp0mcp-timeout-hook.js" "%~dp0index.js" %*原来是 node.exe index.js,现在变成 node.exe --require mcp-timeout-hook.js index.js。--require 的语义是"在执行主脚本之前先加载这个模块",正好满足"编译前注入"的需求。
toolkit.py(便携版工具包的环境设置):
if "MCP_REQUEST_TIMEOUT_MS" not in os.environ:
os.environ["MCP_REQUEST_TIMEOUT_MS"] = "600000"在环境初始化阶段设默认值,用户可以通过外部环境变量覆盖。
自测
写了 9 个自动化测试用例验证 hook 的正确性:
| 测试项 | 验证目标 |
|---|---|
| hook 文件存在 | 部署完整性 |
index.js 中能找到目标模式 | 确认 SDK 版本匹配 |
| 正则替换能命中目标 | hook 功能正确 |
22 个 6e4 中仅命中 1 个 | 零误伤 |
| 替换后无残留的默认 60s | 彻底性 |
| 带 hook 子进程默认超时 = 600000 | 端到端验证 |
| 显式 timeout 参数不受影响 | 不破坏正常行为 |
| 无 hook 时保持原始 60000 | 对照组 |
| 自定义超时值 300000 | 环境变量传递 |
全部通过后,又跑了 3 轮连续超时实测:
| 次数 | 调用时间 | 超时时间 | 间隔 |
|---|---|---|---|
| 1 | 23:36:00 | 23:46:15 | 10m14s |
| 2 | 23:46:15 | 23:56:31 | 10m16s |
| 3 | 23:56:31 | 00:06:49 | 10m18s |
3 次都稳定在约 10 分钟,和 MCP_REQUEST_TIMEOUT_MS=600000 设置一致。
局限性和后续
这个方案本质是对压缩代码的文本级补丁,有几个已知风险:
SDK 版本升级可能导致 hook 失效。如果 Cursor 更新了 MCP SDK,压缩后的代码模式可能变化,正则不再匹配。此时 hook 会静默失效,回退到 60 秒默认值——不会崩溃,但超时问题会复现。自测脚本中的"目标模式存在性检查"就是为了快速发现这种情况。
只改了 fallback 默认值。如果未来 Cursor 在 callTool 调用时显式传入了 timeout: 60000,hook 就管不到了——它只替换 ?? 6e4 这个 fallback 路径。不过从目前的代码看,callTool 根本没传 timeout 选项,所以这个风险暂时不存在。
更理想的解决方案是 Cursor 官方暴露 MCP 超时配置(类似 mcp.json 里加个 requestTimeoutMs 字段),或者 MCP SDK 本身把默认超时调长。已经有人在 Cursor 社区提过这个需求,但目前没有官方回应。在此之前,26 行 hook 是性价比最高的方案。