Skip to content

给 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、没有人类可读的函数名、变量全部混淆。要在这种代码里找到超时逻辑,只能靠搜索。

先搜错误信息:

bash
rg "Request timed out" index.js

找到了。然后向前搜 6e4(60000 的科学计数法)结合 _setupTimeout

bash
rg -o ".{0,200}_setupTimeout.{0,200}" index.js

关键代码段(格式化后):

javascript
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()

bash
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 行:

javascript
'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 检查文件是否包含 _setupTimeoutRequest 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 入口):

batch
@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(便携版工具包的环境设置):

python
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 轮连续超时实测:

次数调用时间超时时间间隔
123:36:0023:46:1510m14s
223:46:1523:56:3110m16s
323:56:3100:06:4910m18s

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 是性价比最高的方案。

最后更新于:

Hosted by GitHub Pages