fix(mcp): give npx/uvx servers time for their first download

QA friction audit (codewhale-ops dd2c253d row 9): /mcp add recommended
playwright wrote config, but the first connect failed because the cold
npx @playwright/mcp download overran the 30 s connect budget. The error
read "MCP SDK request failed before an admitted reply: mcp/open timed out
after 30s; the host was told to cancel it".

- A stdio server launched through npx, pnpx, bunx or uvx with no
  per-server connect_timeout now gets max(global, 120 s). An explicit
  per-server value still wins.
- When initialize fails because the connect budget ran out, the error now
  leads with "MCP server '<name>' didn't start within Ns; retry with
  /mcp restart, or raise its connect_timeout", with the transport detail
  still in the chain.
- docs/MCP.md states the floor.

Verified on the shared tree (same code, pre-S19 layout):
scripts/dev-test.sh tui mcp 584/584 passed. Ported to the S19 layout
(crates/mcp/src/config.rs) by text; rustfmt clean. The live npx cold
start was not re-run here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-27 22:50:09 -07:00
1 parent dc53e0ad47
commit b8afed5f27
3 files changed
+40 -4

No files matched your search

+26 -1
View File
@@ -203,13 +203,38 @@ pub struct McpServerOAuthConfig {
pub client_metadata_url: Option<String>,
}
const PACKAGE_RUNNER_CONNECT_TIMEOUT_SECS: u64 = 120;
fn default_enabled() -> bool {
true
}
impl McpServerConfig {
pub fn effective_connect_timeout(&self, global: &McpTimeouts) -> u64 {
self.connect_timeout.unwrap_or(global.connect_timeout)
self.connect_timeout.unwrap_or_else(|| {
if self.launches_package_runner() {
global
.connect_timeout
.max(PACKAGE_RUNNER_CONNECT_TIMEOUT_SECS)
} else {
global.connect_timeout
}
})
}
fn launches_package_runner(&self) -> bool {
self.url.is_none()
&& self
.command
.as_deref()
.and_then(|command| std::path::Path::new(command).file_stem())
.and_then(|stem| stem.to_str())
.is_some_and(|stem| {
matches!(
stem.to_ascii_lowercase().as_str(),
"npx" | "pnpx" | "bunx" | "uvx"
)
})
}
pub fn effective_execute_timeout(&self, global: &McpTimeouts) -> u64 {
+13 -2
View File
@@ -2077,9 +2077,20 @@ impl McpConnection {
}
// Initialize with timeout
tokio::time::timeout(Duration::from_secs(connect_timeout_secs), conn.initialize())
let connect_deadline = Duration::from_secs(connect_timeout_secs);
let initialize_started = std::time::Instant::now();
let initialized = tokio::time::timeout(connect_deadline, conn.initialize())
.await
.with_context(|| format!("MCP server '{name}' initialization timed out"))??;
.map_err(anyhow::Error::from)
.and_then(|result| result);
if let Err(error) = initialized {
if initialize_started.elapsed() >= connect_deadline {
return Err(error.context(format!(
"MCP server '{name}' didn't start within {connect_timeout_secs}s; retry with /mcp restart, or raise its connect_timeout"
)));
}
return Err(error);
}
conn.discover_all()
.await
+1 -1
View File
@@ -719,7 +719,7 @@ Per-server settings:
- `args` (array of strings, optional)
- `env` (object, optional)
- `connect_timeout`, `execute_timeout`, `read_timeout` (seconds, optional). A per-server value overrides the global `timeouts` block, and each budget is independent of the others:
- `connect_timeout` (default 30) covers spawn, `initialize` and the first `tools/list`, so a cold `uvx`/`npx` package download counts against it.
- `connect_timeout` (default 30) covers spawn, `initialize` and the first `tools/list`, so a cold `uvx`/`npx` package download counts against it. A server launched through `npx`, `pnpx`, `bunx` or `uvx` without its own `connect_timeout` gets at least 120 seconds for that first download.
- `execute_timeout` (default 1800) is the budget for each `tools/call` and `prompts/get`. An explicit shorter value is respected, and `read_timeout` never cuts a running tool short.
- `read_timeout` (default 120) bounds each reply wait during the handshake and tool discovery, and is the budget for `resources/read`.
- A request that runs out of budget fails with a timeout. The connection is kept, and a reply that arrives later is discarded instead of being handed to another call. Interrupting the turn stops the wait at once, but Codewhale does not send `notifications/cancelled` yet, so a server that handles one request at a time finishes the abandoned call before it answers the next one.