Merge pull request #6802 from Hmbown/integration/pr6741-mcp-budget-20260930

Verified original PR #6802 at c7694c140e: exact-head Linux, macOS and Windows tests and doctests, required checks and resolved review threads. Contributor authorship and existing history are preserved.

Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
Hunter Bown authored and GitHub committed 2026-09-30 21:48:43 -07:00
commit 7e565f716e
4 files changed
+528 -52

No files matched your search

+125 -44
View File
@@ -532,8 +532,15 @@ pub struct McpTimeouts {
fn default_connect_timeout() -> u64 {
30
}
// 30 minutes: an MCP tool call legitimately runs minutes — builds, test
// suites, scrapes, remote jobs. The old 60s default returned "timed out" to
// the model for healthy-but-slow tools, which then retried and compounded
// the cost. Per-server and global `execute_timeout` overrides still win.
// Scope note: `prompts/get` also routes through `effective_execute_timeout`,
// so it inherits this default; its server-side template work is normally
// fast, but the override knob is the intended way to keep it tight.
fn default_execute_timeout() -> u64 {
60
1800
}
fn default_read_timeout() -> u64 {
120
@@ -1756,6 +1763,20 @@ impl Drop for PendingAuthorityWatch {
}
}
/// Total request ceiling handed to the HTTP client: it must cover the longest
/// request the connection carries (`tools/call` at the execute budget), so a
/// raised `execute_timeout` governs HTTP servers too. This is a ceiling for
/// the transport, not the read knob.
///
/// Streamable HTTP reads the reply inside the POST; `call_method` bounds the
/// send and the receive with the request's own budget, so this ceiling is
/// only the transport's outer safety net for requests without one.
fn http_request_ceiling_secs(config: &McpServerConfig, global: &McpTimeouts) -> u64 {
config
.effective_read_timeout(global)
.max(config.effective_execute_timeout(global))
}
impl McpConnection {
/// Connect to an MCP server and initialize it.
///
@@ -1817,7 +1838,12 @@ impl McpConnection {
config.allow_private_network,
network_policy,
Duration::from_secs(connect_timeout_secs),
Duration::from_secs(read_timeout_secs),
// Transport total-request ceiling, not the read knob: it must
// cover the longest request this connection carries
// (`tools/call` at the execute budget), so a raised
// `execute_timeout` governs HTTP servers too. The read knob
// itself stays intact for the connection-level waits below.
Duration::from_secs(http_request_ceiling_secs(&config, global_timeouts)),
)?;
let oauth_runtime = if config.reviewed_plugin.is_some() {
None
@@ -2408,31 +2434,67 @@ impl McpConnection {
}
let call_id = self.next_id();
if let Err(error) = self
.send(serde_json::json!({
// One deadline bounds the whole request. Streamable HTTP reads the
// reply inside the POST, so a budget on the receive alone would leave
// that transport to its client-wide ceiling (the larger of the read
// and execute knobs) instead of this request's own budget.
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
let expired_error = anyhow::anyhow!(
"MCP method '{}' on server '{}' timed out after {}s",
method,
self.name,
timeout_secs
);
match tokio::time::timeout_at(
deadline,
self.send(serde_json::json!({
"jsonrpc": "2.0",
"id": &call_id,
"method": method,
"params": params
}))
.await
})),
)
.await
{
return self.finish_guarded_error(error).await;
Ok(Ok(())) => {}
Ok(Err(error)) => return self.finish_guarded_error(error).await,
Err(_) => {
// A send abandoned mid-write can leave a partial frame on a
// stream transport, so the frame boundary is unknown: rebuild
// the connection rather than reuse it.
self.state = ConnectionState::Disconnected;
return self.finish_guarded_error(expired_error).await;
}
}
let response =
match tokio::time::timeout(Duration::from_secs(timeout_secs), self.recv(call_id))
.await
.with_context(|| {
format!(
"MCP method '{}' on server '{}' timed out after {}s",
method, self.name, timeout_secs
)
}) {
Ok(Ok(response)) => response,
Ok(Err(error)) => return self.finish_guarded_error(error).await,
Err(error) => return self.finish_guarded_error(error).await,
};
// The request's own budget is its only receive deadline. A per-frame
// read-knob wait here either undercut it — a server is silent for the
// whole execution of a tool call, so the knob fired first, marked the
// connection Disconnected, and capped a raised `execute_timeout` — or
// tied with it, leaving the connection's fate to timer order. On
// expiry the request is abandoned and the connection kept: a late
// reply carries the abandoned id and the next receive skips it.
//
// Known limitation: the server is never told a request was abandoned
// (no `notifications/cancelled`), whether by this budget or by the
// caller dropping the call (turn cancellation). A server that handles
// requests one at a time answers the next call only after finishing
// the abandoned one, so that call can wait up to its own budget —
// 1800s for `tools/call` by default. Cancelling `cancel_token` instead
// marks the connection dead, so the pool rebuilds it (a new child for
// stdio) before the next call.
let response = match tokio::time::timeout_at(deadline, self.recv_reply(call_id, None))
.await
.with_context(|| {
format!(
"MCP method '{}' on server '{}' timed out after {}s",
method, self.name, timeout_secs
)
}) {
Ok(Ok(response)) => response,
Ok(Err(error)) => return self.finish_guarded_error(error).await,
Err(error) => return self.finish_guarded_error(error).await,
};
if let Some(error) = response.get("error") {
if self.config.reviewed_plugin.is_some() {
@@ -2551,34 +2613,53 @@ impl McpConnection {
result
}
/// Handshake and discovery receive: each frame wait is bounded by the read
/// knob, and a server that stays silent past it is treated as dead.
async fn recv(&mut self, expected_id: String) -> Result<serde_json::Value> {
loop {
let bytes = match tokio::time::timeout(
Duration::from_secs(self.read_timeout_secs),
async {
tokio::select! {
biased;
_ = self.cancel_token.cancelled() => {
anyhow::bail!("MCP connection '{}' was cancelled", self.name)
}
result = self.transport.recv() => result,
}
},
)
self.recv_reply(expected_id, Some(self.read_timeout_secs))
.await
{
Ok(result) => result.inspect_err(|_e| {
self.state = ConnectionState::Disconnected;
})?,
Err(_) => {
self.state = ConnectionState::Disconnected;
anyhow::bail!(
"Timed out waiting for MCP JSON-RPC response from server '{}' after {}s",
self.name,
self.read_timeout_secs
);
}
/// The next transport frame, unless the connection is cancelled first.
async fn next_frame(&mut self) -> Result<Vec<u8>> {
tokio::select! {
biased;
_ = self.cancel_token.cancelled() => {
anyhow::bail!("MCP connection '{}' was cancelled", self.name)
}
result = self.transport.recv() => result,
}
}
/// Receive the reply to `expected_id`, skipping notifications and replies
/// to other (abandoned) requests. `frame_timeout_secs` bounds each frame
/// wait and treats its expiry as a dead connection; `None` leaves the
/// whole wait to the caller's own request budget.
async fn recv_reply(
&mut self,
expected_id: String,
frame_timeout_secs: Option<u64>,
) -> Result<serde_json::Value> {
loop {
let frame = match frame_timeout_secs {
Some(secs) => {
match tokio::time::timeout(Duration::from_secs(secs), self.next_frame()).await {
Ok(frame) => frame,
Err(_) => {
self.state = ConnectionState::Disconnected;
anyhow::bail!(
"Timed out waiting for MCP JSON-RPC response from server '{}' after {}s",
self.name,
secs
);
}
}
}
None => self.next_frame().await,
};
let bytes = frame.inspect_err(|_e| {
self.state = ConnectionState::Disconnected;
})?;
let value: serde_json::Value = match serde_json::from_slice(&bytes) {
Ok(value) => value,
Err(err) => {
+387 -2
View File
@@ -79,7 +79,7 @@ fn mark_workspace_trusted(workspace: &Path) -> WorkspaceTrustConfigGuard {
fn test_mcp_config_defaults() {
let config = McpConfig::default();
assert_eq!(config.timeouts.connect_timeout, 30);
assert_eq!(config.timeouts.execute_timeout, 60);
assert_eq!(config.timeouts.execute_timeout, 1800);
assert_eq!(config.timeouts.read_timeout, 120);
assert!(config.servers.is_empty());
}
@@ -2805,7 +2805,10 @@ fn test_server_effective_timeouts() {
};
assert_eq!(server_with_override.effective_connect_timeout(&global), 20);
assert_eq!(server_with_override.effective_execute_timeout(&global), 60); // global default
assert_eq!(
server_with_override.effective_execute_timeout(&global),
1800
); // global default
assert_eq!(server_with_override.effective_read_timeout(&global), 180);
}
@@ -2884,6 +2887,21 @@ impl McpTransport for ScriptedThenHangingTransport {
}
}
/// A transport that answers inside `send`, as Streamable HTTP reads the reply
/// within the POST, and never finishes that send.
struct HangingSendTransport;
#[async_trait::async_trait]
impl McpTransport for HangingSendTransport {
async fn send(&mut self, _msg: Vec<u8>) -> Result<()> {
std::future::pending().await
}
async fn recv(&mut self) -> Result<Vec<u8>> {
std::future::pending().await
}
}
/// A transport whose write side is gone — the shape a crashed or exited
/// stdio MCP child leaves behind (EPIPE on the next `write_all`).
struct FailingSendTransport;
@@ -3047,6 +3065,126 @@ fn json_frame(value: serde_json::Value) -> Vec<u8> {
serde_json::to_vec(&value).unwrap()
}
#[test]
fn http_request_ceiling_covers_the_execute_budget() {
let mut config = test_server_config();
config.execute_timeout = Some(1800);
config.read_timeout = Some(120);
let global = McpTimeouts {
connect_timeout: 10,
execute_timeout: 1800,
read_timeout: 120,
};
assert_eq!(http_request_ceiling_secs(&config, &global), 1800);
// A per-server read knob above both stays intact.
config.read_timeout = Some(3600);
assert_eq!(http_request_ceiling_secs(&config, &global), 3600);
}
/// A transport that stays silent for a fixed delay, then answers with a
/// matching result — the shape of an MCP server executing a long tool call.
struct DelayedResponseTransport {
delay: Duration,
pending: Option<Vec<u8>>,
}
#[async_trait::async_trait]
impl McpTransport for DelayedResponseTransport {
async fn send(&mut self, msg: Vec<u8>) -> Result<()> {
// Echo the request id so every call gets its matching response.
let request: serde_json::Value = serde_json::from_slice(&msg)?;
let response = serde_json::json!({
"jsonrpc": "2.0",
"id": request["id"].clone(),
"result": {"ok": true}
});
self.pending = Some(json_frame(response));
Ok(())
}
async fn recv(&mut self) -> Result<Vec<u8>> {
tokio::time::sleep(self.delay).await;
self.pending.take().context("delayed transport exhausted")
}
}
#[tokio::test]
async fn a_tool_response_after_the_read_knob_still_completes_within_the_execute_budget() {
let mut connection = test_connection(Box::new(DelayedResponseTransport {
// The response arrives after the read knob (1s) but well inside the
// execute budget (30s): the read knob must not bound a request's
// receive and mark the connection dead before the reply lands.
delay: Duration::from_millis(1500),
pending: None,
}));
connection.read_timeout_secs = 1;
let result = connection
.call_tool("slow", serde_json::json!({}), 30)
.await
.expect("a silent tool execution must not be cut off by the read knob");
assert_eq!(result, serde_json::json!({"ok": true}));
assert!(
connection.is_ready(),
"a completed call keeps the connection"
);
}
/// A wedged server fails a request at the request's own budget, and the
/// request is abandoned, not the connection: its late reply carries the
/// abandoned id and is skipped (see the stdio fixtures below). The read knob
/// (1s) and the budget (2s) are distinct on purpose: equal deadlines used to
/// race, and whichever timer won decided whether the connection survived.
/// The per-frame read-knob disconnect is still pinned for the handshake by
/// `recv_times_out_waiting_for_mcp_response_and_disconnects`.
#[tokio::test]
async fn a_wedged_request_fails_at_its_own_budget_and_keeps_the_connection() {
let mut connection = test_connection(Box::new(HangingValueTransport {
sent: Arc::new(Mutex::new(Vec::new())),
}));
connection.read_timeout_secs = 1;
let error = connection
.read_resource("file:///wedged", 2)
.await
.expect_err("a wedged server must fail the request at its budget");
assert!(
error
.to_string()
.contains("MCP method 'resources/read' on server 'mock' timed out after 2s"),
"the request budget, not the read knob, must end the request: {error:#}"
);
assert!(
connection.is_ready(),
"an expired request must not declare the connection dead"
);
}
/// A request whose transport blocks inside `send` (Streamable HTTP) still
/// ends at its own budget, not at the transport's larger client ceiling, and
/// the connection is rebuilt because the abandoned write may be partial.
#[tokio::test]
async fn a_request_blocked_inside_send_ends_at_its_own_budget() {
let mut connection = test_connection(Box::new(HangingSendTransport));
connection.read_timeout_secs = 30;
let error = tokio::time::timeout(
std::time::Duration::from_secs(10),
connection.read_resource("file:///wedged-post", 1),
)
.await
.expect("the request budget, not the transport, must end a blocked send")
.expect_err("a POST that never completes must end at the request budget");
assert!(
error
.to_string()
.contains("MCP method 'resources/read' on server 'mock' timed out after 1s"),
"{error:#}"
);
assert!(
!connection.is_ready(),
"a send abandoned mid-write leaves the frame boundary unknown, so the connection is rebuilt"
);
}
#[tokio::test]
async fn call_method_skips_notifications_and_unmatched_responses() {
let sent = Arc::new(Mutex::new(Vec::new()));
@@ -9093,6 +9231,253 @@ async fn json_rpc_session_error_on_tool_call_is_not_replayed() {
assert_eq!(server_call_count(&call_log), 2);
}
/// A sequential stdio server for the `tools/call` deadline fixtures. Every
/// `initialize` appends `init <pid>` to `$INIT_LOG`, so a test can tell a
/// reused connection from a rebuilt one; every `tools/call` appends
/// `<tool> <id>` to `$CALL_LOG` before it runs, and its reply text is that
/// same line, so a test can tell which request a reply answers. `slow` sleeps
/// `$SLOW_SECS` before replying, `hang` never replies, `fast` replies at once.
#[cfg(unix)]
const DEADLINE_STDIO_SERVER: &str = r#"#!/bin/sh
while IFS= read -r line; do
id=$(printf '%s\n' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
case "$line" in
*'"method":"notifications/'*)
;;
*'"method":"initialize"'*)
echo "init $$" >> "$INIT_LOG"
printf '{"jsonrpc":"2.0","id":"%s","result":{"protocolVersion":"2024-11-05","serverInfo":{"name":"deadline","version":"1.0.0"},"capabilities":{"tools":{}}}}\n' "$id"
;;
*'"method":"tools/list"'*)
printf '{"jsonrpc":"2.0","id":"%s","result":{"tools":[{"name":"slow","inputSchema":{"type":"object"}},{"name":"fast","inputSchema":{"type":"object"}},{"name":"hang","inputSchema":{"type":"object"}}]}}\n' "$id"
;;
*'"method":"tools/call"'*)
tool=$(printf '%s\n' "$line" | sed -n 's/.*"name":"\([^"]*\)".*/\1/p')
echo "$tool $id" >> "$CALL_LOG"
case "$tool" in
slow) sleep "$SLOW_SECS" ;;
hang) while :; do sleep 0.05; done ;;
esac
printf '{"jsonrpc":"2.0","id":"%s","result":{"content":[{"type":"text","text":"%s %s"}]}}\n' "$id" "$tool" "$id"
;;
*)
[ -n "$id" ] && printf '{"jsonrpc":"2.0","id":"%s","result":{}}\n' "$id"
;;
esac
done
"#;
/// A pool with one `deadline` stdio server; returns it with the call and
/// init log paths.
#[cfg(unix)]
fn deadline_stdio_pool(
dir: &Path,
slow_secs: &str,
configure: impl FnOnce(&mut McpServerConfig),
) -> (McpPool, PathBuf, PathBuf) {
let script = dir.join("server.sh");
fs::write(&script, DEADLINE_STDIO_SERVER).unwrap();
let call_log = dir.join("calls.log");
let init_log = dir.join("inits.log");
let mut server = test_server_config();
server.command = Some("sh".to_string());
server.args = vec![script.to_string_lossy().into_owned()];
for (key, value) in [
("CALL_LOG", call_log.to_string_lossy().into_owned()),
("INIT_LOG", init_log.to_string_lossy().into_owned()),
("SLOW_SECS", slow_secs.to_string()),
] {
server.env.insert(key.to_string(), value);
}
configure(&mut server);
let mut cfg = McpConfig::default();
cfg.servers.insert("deadline".to_string(), server);
(McpPool::new(cfg), call_log, init_log)
}
#[cfg(unix)]
fn log_lines(path: &Path) -> Vec<String> {
fs::read_to_string(path)
.map(|log| log.lines().map(str::to_string).collect())
.unwrap_or_default()
}
/// The result `DEADLINE_STDIO_SERVER` returns for the call it logged as `call`.
#[cfg(unix)]
fn deadline_reply(call: &str) -> serde_json::Value {
serde_json::json!({ "content": [{ "type": "text", "text": call }] })
}
/// #6741 over a real stdio child: a `tools/call` whose reply arrives after
/// the read knob — the receive budget every request used to get — but inside
/// the execute budget completes, and the next call on the same connection gets
/// its own reply. The late frame is consumed once, by the request it answers;
/// nothing is replayed and the child is not restarted.
#[cfg(unix)]
#[tokio::test]
async fn stdio_tool_reply_after_the_read_knob_completes_within_the_execute_budget() {
let dir = tempfile::tempdir().unwrap();
let (mut pool, call_log, init_log) = deadline_stdio_pool(dir.path(), "3", |server| {
server.read_timeout = Some(1);
server.execute_timeout = Some(30);
});
let started = std::time::Instant::now();
let slow = pool
.call_tool("mcp_deadline_slow", serde_json::json!({}))
.await
.expect("a reply inside the execute budget must not be cut off at the read knob");
assert!(
started.elapsed() >= Duration::from_secs(3),
"the reply must really have outlived the 1s read knob"
);
let fast = pool
.call_tool("mcp_deadline_fast", serde_json::json!({}))
.await
.expect("the next call on the same connection must get its own reply");
let calls = log_lines(&call_log);
assert_eq!(calls.len(), 2, "neither call may be replayed: {calls:?}");
assert!(calls[0].starts_with("slow ") && calls[1].starts_with("fast "));
assert_eq!(slow, deadline_reply(&calls[0]));
assert_eq!(fast, deadline_reply(&calls[1]));
assert_eq!(
log_lines(&init_log).len(),
1,
"both calls must share one connection and child"
);
}
/// An explicit `execute_timeout` shorter than the read knob still governs a
/// real stdio `tools/call`: the call fails at its own 2s budget, not at the
/// 120s read knob or the 1800s default. The request is abandoned, not the
/// connection: the child's late reply to it reaches the pipe ahead of the
/// next call's reply and is skipped, so the next call gets its own reply on
/// the same child.
#[cfg(unix)]
#[tokio::test]
async fn explicit_shorter_execute_budget_ends_a_stdio_tool_call_and_skips_its_late_reply() {
let dir = tempfile::tempdir().unwrap();
let (mut pool, call_log, init_log) = deadline_stdio_pool(dir.path(), "3", |server| {
server.execute_timeout = Some(2);
});
pool.get_or_connect("deadline").await.unwrap();
let started = std::time::Instant::now();
let error = pool
.call_tool("mcp_deadline_slow", serde_json::json!({}))
.await
.expect_err("an explicit shorter budget must end the call");
let elapsed = started.elapsed();
assert!(
format!("{error:#}")
.contains("MCP method 'tools/call' on server 'deadline' timed out after 2s"),
"unexpected error: {error:#}"
);
assert!(
elapsed < Duration::from_secs(3),
"the explicit budget, not the 3s reply, must end the call: {elapsed:?}"
);
let fast = pool
.call_tool("mcp_deadline_fast", serde_json::json!({}))
.await
.expect("the kept connection must answer the next call");
let calls = log_lines(&call_log);
assert_eq!(calls.len(), 2, "neither call may be replayed: {calls:?}");
assert!(calls[0].starts_with("slow ") && calls[1].starts_with("fast "));
assert_eq!(
fast,
deadline_reply(&calls[1]),
"the abandoned call's late reply must not answer the next call"
);
assert_eq!(
log_lines(&init_log).len(),
1,
"an expired request must not rebuild the connection"
);
}
/// Owned cancellation of an in-flight stdio `tools/call` under the default
/// 1800s execute budget: cancelling the connection's own token ends the call
/// promptly instead of waiting out the budget, and marks the connection dead.
/// The next call rebuilds it on a fresh child, the cancelled call's child is
/// terminated rather than left running, and the cancelled call is not replayed.
#[cfg(unix)]
#[tokio::test]
async fn cancelling_an_inflight_stdio_tool_call_does_not_wait_for_the_execute_budget() {
let dir = tempfile::tempdir().unwrap();
let (mut pool, call_log, init_log) = deadline_stdio_pool(dir.path(), "0", |_| {});
assert_eq!(
pool.config.servers["deadline"].effective_execute_timeout(&pool.config.timeouts),
1800,
"the fixture must run under the default execute budget"
);
pool.get_or_connect("deadline").await.unwrap();
let cancel = pool.connections["deadline"].cancel_token.clone();
let call = tokio::spawn(async move {
let result = pool
.call_tool("mcp_deadline_hang", serde_json::json!({}))
.await;
(pool, result)
});
tokio::time::timeout(Duration::from_secs(10), async {
while log_lines(&call_log).is_empty() {
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("the child never received the call");
cancel.cancel();
let (mut pool, result) = tokio::time::timeout(Duration::from_secs(5), call)
.await
.expect("cancellation must not wait for the execute budget")
.expect("the call task panicked");
let error = result.expect_err("a cancelled call must not report success");
assert!(
format!("{error:#}").contains("MCP connection 'deadline' was cancelled"),
"unexpected error: {error:#}"
);
assert!(
pool.connected_servers().is_empty(),
"a cancelled connection must not be reused"
);
let fast = pool
.call_tool("mcp_deadline_fast", serde_json::json!({}))
.await
.expect("the next call must rebuild the connection");
let calls = log_lines(&call_log);
assert_eq!(
calls.len(),
2,
"the cancelled call must not be replayed: {calls:?}"
);
assert!(calls[0].starts_with("hang ") && calls[1].starts_with("fast "));
assert_eq!(fast, deadline_reply(&calls[1]));
let inits = log_lines(&init_log);
assert_eq!(
inits.len(),
2,
"the next call must run on a fresh child: {inits:?}"
);
let cancelled_pid: i32 = inits[0]
.strip_prefix("init ")
.and_then(|pid| pid.parse().ok())
.expect("the init log records the child pid");
tokio::time::timeout(STDIO_SHUTDOWN_GRACE + Duration::from_secs(1), async {
// SAFETY: signal zero only checks whether the pid still exists.
while unsafe { libc::kill(cancelled_pid, 0) } == 0 {
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("the cancelled call's child must be terminated, not left running");
}
/// A Streamable HTTP server that reads the whole `tools/call` POST and then
/// drops the connection may have run it: the call runs once and the next
/// call succeeds on a rebuilt connection.
+8 -3
View File
@@ -378,8 +378,8 @@ The CLI also exposes helper tools when MCP is enabled:
```json
{
"timeouts": {
"connect_timeout": 10,
"execute_timeout": 60,
"connect_timeout": 30,
"execute_timeout": 1800,
"read_timeout": 120
},
"servers": {
@@ -490,7 +490,12 @@ Per-server settings:
- `command` (string, required)
- `args` (array of strings, optional)
- `env` (object, optional)
- `connect_timeout`, `execute_timeout`, `read_timeout` (seconds, optional). `connect_timeout` defaults to 30 and covers spawn, `initialize` and the first `tools/list`, so a cold `uvx`/`npx` package download counts against it.
- `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.
- `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.
- Streamable HTTP servers return the reply inside the POST itself; the request's own budget bounds that POST too. A request that expires while still sending closes the connection, which is rebuilt before the next call.
- `disabled` (bool, optional)
- `enabled` (bool, optional, default `true`)
- `required` (bool, optional): startup/connect validation fails if this server cannot initialize.
+8 -3
View File
@@ -248,8 +248,8 @@ Codewhale 同时读取 `servers` 和 `mcpServers`,因此设置页生成的片
```json
{
"timeouts": {
"connect_timeout": 10,
"execute_timeout": 60,
"connect_timeout": 30,
"execute_timeout": 1800,
"read_timeout": 120
},
"servers": {
@@ -341,7 +341,12 @@ codewhale-tui mcp tools codewhale
- `command`(字符串,必需)
- `args`(字符串数组,可选)
- `env`(对象,可选)
- `connect_timeout`、`execute_timeout`、`read_timeout`(秒,可选)
- `connect_timeout`、`execute_timeout`、`read_timeout`(秒,可选)。服务器级的值会覆盖全局 `timeouts` 块,三个预算彼此独立:
- `connect_timeout`(默认 30)覆盖进程启动、`initialize` 和首次 `tools/list`,因此首次通过 `uvx`/`npx` 下载包的时间也计入其中。
- `execute_timeout`(默认 1800)是每次 `tools/call` 和 `prompts/get` 的预算。显式设置的更短值会被遵守,`read_timeout` 不会提前截断正在运行的工具。
- `read_timeout`(默认 120)限制握手和工具发现期间每次等待回复的时间,也是 `resources/read` 的预算。
- 请求超出预算时以超时错误失败。连接会被保留,之后迟到的回复会被丢弃,不会被交给其他调用。中断当前轮次会立即停止等待,但 Codewhale 目前还不会发送 `notifications/cancelled`,因此一次只处理一个请求的服务器会先完成被放弃的调用,再回答下一个请求。
- Streamable HTTP 服务器在 POST 响应中直接返回回复;该 POST 同样受请求自身时限约束。若请求在发送阶段超时,连接会被关闭,并在下一次调用前重建。
- `disabled`(布尔值,可选)
- `enabled`(布尔值,可选,默认 `true`)
- `required`(布尔值,可选):如果该服务器无法初始化,启动/连接验证会失败。