fix(mcp): kill stdio child on drop, cap SSE body, pipe stderr, restrict env expansion#3926
Conversation
…r, restrict env expansion tokio runtime when the connection is dropped without an explicit close call. Also update disconnect_mcp_server in the kernel to call conn.close().await explicitly after extracting connections from the lock, ensuring the stdio child is reaped before hot-reload brings up a new connection. SSE and HttpCompat transports. Checks Content-Length first (fast path) then reads up to 16 MiB; rejects oversized bodies with an error instead of OOMing. TokioChildProcess builder instead of inheriting the daemon fd. A background drain task reads the pipe and logs each line at DEBUG with a 256-byte cap and a 100-line hard stop, preventing pipe stalls without flooding logs. (safe system vars + the operator-declared env entries). $VAR tokens not in the allowlist are left verbatim — std::env::var() is never called for them, so daemon secrets (ANTHROPIC_API_KEY, GROQ_API_KEY, etc.) cannot leak into child process arguments even if they happen to be referenced in a template.
houko
left a comment
There was a problem hiding this comment.
Two notes from the automated daily review pass — nothing blocking, both are clarifications/cleanups rather than correctness issues.
The implementation itself looks solid: explicit close().await before releasing the lock in disconnect_mcp_server and reconcile_mcp_servers is the right approach for subprocess reaping (#3800); the body-cap fast-path on Content-Length is good (#3801); the stderr drain with line/count caps is well-bounded (#3805); and the env-var allowlist correctly blocks undeclared daemon secrets from leaking into MCP child args (#3823). Tests cover all the new paths cleanly.
Generated by Claude Code
| // Reject responses whose Content-Type is neither JSON nor an SSE | ||
| // stream — anything else is almost certainly a proxy error page or a | ||
| // misconfigured server, and decoding it as JSON-RPC would silently | ||
| // produce garbage. (#3802) |
There was a problem hiding this comment.
Issue reference mismatch: the comment says (#3802) but the PR title lists fix/mcp-3800-3801-3805-3823 (no #3802), and the PR description attributes the Content-Type validation to closes #3801.
If Content-Type validation really belongs to a separate issue #3802, please add it to the PR title and Fixes #3802 in the description so it auto-closes. If it's part of #3801, change the comment here to (#3801) to keep things consistent.
Generated by Claude Code
| let inner = std::mem::replace( | ||
| &mut self.inner, | ||
| McpInner::HttpCompat { | ||
| client: reqwest::Client::new(), |
There was a problem hiding this comment.
mem::replace needs a value to put back, so this constructs a reqwest::Client purely as a sentinel — an HTTP client with a connection pool — just to satisfy the type system. The logic is correct but it's wasteful and reads oddly in a Drop impl.
A cleaner approach is to add a McpInner::Closed variant:
enum McpInner {
Rmcp(DynRmcpClient),
Sse { client: reqwest::Client, url: String, next_id: u64 },
HttpCompat { client: reqwest::Client },
Closed, // sentinel used only by Drop
}Then Drop becomes:
let inner = std::mem::replace(&mut self.inner, McpInner::Closed);No allocation, no misleading reqwest::Client::new() call, and any future match on McpInner that forgets to handle Closed will get a compile-time exhaustiveness error rather than silently operating on a ghost client.
Generated by Claude Code
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: be37362e95
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let mut lines_read: u32 = 0; | ||
| const MAX_LINE_BYTES: usize = 256; | ||
| const MAX_LINES: u32 = 100; | ||
| while lines_read < MAX_LINES { |
There was a problem hiding this comment.
Keep draining stderr after reaching log line cap
The drain task stops entirely once lines_read reaches MAX_LINES, which leaves the child process's stderr pipe unread for the rest of its lifetime. In deployments where an MCP server continues writing diagnostics after startup, the pipe buffer can fill and block the child on stderr writes, causing stalled/hung MCP tool calls even though the connection is still considered active. Continue consuming stderr after the cap and only suppress logging output.
Useful? React with 👍 / 👎.
| let server_name_for_log = resolved_command.clone(); | ||
| tokio::spawn(async move { | ||
| use tokio::io::BufReader; | ||
| let mut reader = BufReader::new(stderr).lines(); |
There was a problem hiding this comment.
Bound stderr read size before allocating each line
The 256-byte truncation is applied only after next_line() returns, but tokio's line reader buffers the entire line first. A misbehaving or malicious MCP subprocess that emits a very large line without a newline can therefore force unbounded allocation in the daemon before truncation logic runs, creating an OOM risk. Use a bounded read strategy that enforces the limit during IO, not after line materialization.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2bd9fc1013
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let b = response | ||
| .bytes() | ||
| .await | ||
| .map_err(|e| format!("Failed to read response body: {e}"))?; | ||
| if b.len() > MAX_RESPONSE_BYTES { |
There was a problem hiding this comment.
Stream response with cap instead of buffering full body
read_response_bytes_capped still calls response.bytes() before checking b.len(), so for responses without a trustworthy Content-Length (e.g., chunked transfer) the client buffers the entire payload in memory first. A malicious MCP server can therefore still force large allocations/OOM despite the 16 MiB guard, because the cap is enforced only after full materialization.
Useful? React with 👍 / 👎.
On Windows, dropping the TCP server task before reading the request aborts the connection (WSAECONNABORTED, code 10053) and reqwest's .send() returns an Io error instead of the response. Read up to 1 KB of the request first so the client's send() completes cleanly.
#3926's stderr drain loop terminated at line 101: while lines_read < MAX_LINES { match reader.next_line().await { ... lines_read += 1; ... } } After the 100th line the task exits and stops reading the pipe. Once the kernel pipe buffer fills (64 KiB on Linux), the child's next write(stderr) blocks forever — re-creating the exact pipe-stall failure mode #3926 claimed to fix. Any moderately chatty MCP server (e.g. one that prints a banner + per-request log) hits this within seconds. Switch to a that keeps reading after MAX_LINES but skips the format/log work: - lines_read < MAX_LINES → format and debug! the line as before. - lines_read == MAX_LINES → log a single 'capped, still draining' notice so an operator can correlate. - lines_read > MAX_LINES → next_line(), discard, continue. The pipe stays drained indefinitely. The only break paths are now EOF (child closed stderr) and a real I/O error (pipe unusable), which is the only correct shutdown signal.
…llocations read_response_bytes_capped used response.bytes().await — which buffers the FULL body in memory before checking the cap. A server that omits Content-Length (chunked transfer) forces a fresh ~16 MiB allocation per request before rejection, creating real memory pressure under concurrent abuse. The audit of #3926 flagged this as a MEDIUM exploitation surface. Switch to streaming via reqwest::Response::chunk() (already in the crate's reqwest version, no extra feature flag). Loop: match response.chunk().await { Ok(Some(chunk)) if running + chunk > cap => err and abort Ok(Some(chunk)) => extend buf Ok(None) => break Err(e) => err } Pre-allocate Vec capacity from Content-Length when present, clamped to MAX_RESPONSE_BYTES so a malicious large hint can't bypass the fix. When Content-Length is missing the Vec grows organically; the running counter still aborts the moment the next chunk would breach the cap. Worst-case allocation is now MAX_RESPONSE_BYTES + 1 chunk size, not the buffered 16 MiB ceiling regardless of Content-Length.
…n stall The audit of #3926 caught two real properties of the stdio shutdown path: 1. tokio Command's kill_on_drop(true) only sends SIGKILL — it does NOT call wait(). The child becomes a zombie until tokio's internal child reaper task runs. If the reaper is itself starved (runtime spinning down) or already dropped (synchronous drop on a runtime that's been ejected), the zombie persists until the daemon process itself exits. 2. McpConnection::close() awaits client.close() unconditionally — a wedged rmcp transport loop blocks the caller forever. Hot reload (which awaits this) and daemon graceful shutdown both accumulate stalls there. A full fix for the kill_on_drop / no-wait gap requires hijacking the child handle out of rmcp's RunningService, which means either patching rmcp upstream or replacing the transport entirely — far outside follow-up scope. The contained improvement is bounding both close paths so the daemon stops betting on the tokio reaper: * Explicit close (close()): wrap client.close() in a 10s tokio::time::timeout. Healthy servers complete easily; a stuck one stops blocking hot-reload. * Implicit close (Drop): same 10s timeout inside the spawn so a wedged transport doesn't pin a tokio worker. 10s is generous enough that a normally-shutting child finishes without a warning but tight enough that 'still running 10 seconds after kill' is the right signal that something failed and we should rely on OS-level reap-on-process-exit.
#3926's stderr drain loop terminated at line 101: while lines_read < MAX_LINES { match reader.next_line().await { ... lines_read += 1; ... } } After the 100th line the task exits and stops reading the pipe. Once the kernel pipe buffer fills (64 KiB on Linux), the child's next write(stderr) blocks forever — re-creating the exact pipe-stall failure mode #3926 claimed to fix. Any moderately chatty MCP server (e.g. one that prints a banner + per-request log) hits this within seconds. Switch to a that keeps reading after MAX_LINES but skips the format/log work: - lines_read < MAX_LINES → format and debug! the line as before. - lines_read == MAX_LINES → log a single 'capped, still draining' notice so an operator can correlate. - lines_read > MAX_LINES → next_line(), discard, continue. The pipe stays drained indefinitely. The only break paths are now EOF (child closed stderr) and a real I/O error (pipe unusable), which is the only correct shutdown signal.
…llocations read_response_bytes_capped used response.bytes().await — which buffers the FULL body in memory before checking the cap. A server that omits Content-Length (chunked transfer) forces a fresh ~16 MiB allocation per request before rejection, creating real memory pressure under concurrent abuse. The audit of #3926 flagged this as a MEDIUM exploitation surface. Switch to streaming via reqwest::Response::chunk() (already in the crate's reqwest version, no extra feature flag). Loop: match response.chunk().await { Ok(Some(chunk)) if running + chunk > cap => err and abort Ok(Some(chunk)) => extend buf Ok(None) => break Err(e) => err } Pre-allocate Vec capacity from Content-Length when present, clamped to MAX_RESPONSE_BYTES so a malicious large hint can't bypass the fix. When Content-Length is missing the Vec grows organically; the running counter still aborts the moment the next chunk would breach the cap. Worst-case allocation is now MAX_RESPONSE_BYTES + 1 chunk size, not the buffered 16 MiB ceiling regardless of Content-Length.
…n stall The audit of #3926 caught two real properties of the stdio shutdown path: 1. tokio Command's kill_on_drop(true) only sends SIGKILL — it does NOT call wait(). The child becomes a zombie until tokio's internal child reaper task runs. If the reaper is itself starved (runtime spinning down) or already dropped (synchronous drop on a runtime that's been ejected), the zombie persists until the daemon process itself exits. 2. McpConnection::close() awaits client.close() unconditionally — a wedged rmcp transport loop blocks the caller forever. Hot reload (which awaits this) and daemon graceful shutdown both accumulate stalls there. A full fix for the kill_on_drop / no-wait gap requires hijacking the child handle out of rmcp's RunningService, which means either patching rmcp upstream or replacing the transport entirely — far outside follow-up scope. The contained improvement is bounding both close paths so the daemon stops betting on the tokio reaper: * Explicit close (close()): wrap client.close() in a 10s tokio::time::timeout. Healthy servers complete easily; a stuck one stops blocking hot-reload. * Implicit close (Drop): same 10s timeout inside the spawn so a wedged transport doesn't pin a tokio worker. 10s is generous enough that a normally-shutting child finishes without a warning but tight enough that 'still running 10 seconds after kill' is the right signal that something failed and we should rely on OS-level reap-on-process-exit.
…all (#4045) #3926's stderr drain loop terminated at line 101: while lines_read < MAX_LINES { match reader.next_line().await { ... lines_read += 1; ... } } After the 100th line the task exits and stops reading the pipe. Once the kernel pipe buffer fills (64 KiB on Linux), the child's next write(stderr) blocks forever — re-creating the exact pipe-stall failure mode #3926 claimed to fix. Any moderately chatty MCP server (e.g. one that prints a banner + per-request log) hits this within seconds. Switch to a that keeps reading after MAX_LINES but skips the format/log work: - lines_read < MAX_LINES → format and debug! the line as before. - lines_read == MAX_LINES → log a single 'capped, still draining' notice so an operator can correlate. - lines_read > MAX_LINES → next_line(), discard, continue. The pipe stays drained indefinitely. The only break paths are now EOF (child closed stderr) and a real I/O error (pipe unusable), which is the only correct shutdown signal. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…n stall The audit of #3926 caught two real properties of the stdio shutdown path: 1. tokio Command's kill_on_drop(true) only sends SIGKILL — it does NOT call wait(). The child becomes a zombie until tokio's internal child reaper task runs. If the reaper is itself starved (runtime spinning down) or already dropped (synchronous drop on a runtime that's been ejected), the zombie persists until the daemon process itself exits. 2. McpConnection::close() awaits client.close() unconditionally — a wedged rmcp transport loop blocks the caller forever. Hot reload (which awaits this) and daemon graceful shutdown both accumulate stalls there. A full fix for the kill_on_drop / no-wait gap requires hijacking the child handle out of rmcp's RunningService, which means either patching rmcp upstream or replacing the transport entirely — far outside follow-up scope. The contained improvement is bounding both close paths so the daemon stops betting on the tokio reaper: * Explicit close (close()): wrap client.close() in a 10s tokio::time::timeout. Healthy servers complete easily; a stuck one stops blocking hot-reload. * Implicit close (Drop): same 10s timeout inside the spawn so a wedged transport doesn't pin a tokio worker. 10s is generous enough that a normally-shutting child finishes without a warning but tight enough that 'still running 10 seconds after kill' is the right signal that something failed and we should rely on OS-level reap-on-process-exit.
…llocations read_response_bytes_capped used response.bytes().await — which buffers the FULL body in memory before checking the cap. A server that omits Content-Length (chunked transfer) forces a fresh ~16 MiB allocation per request before rejection, creating real memory pressure under concurrent abuse. The audit of #3926 flagged this as a MEDIUM exploitation surface. Switch to streaming via reqwest::Response::chunk() (already in the crate's reqwest version, no extra feature flag). Loop: match response.chunk().await { Ok(Some(chunk)) if running + chunk > cap => err and abort Ok(Some(chunk)) => extend buf Ok(None) => break Err(e) => err } Pre-allocate Vec capacity from Content-Length when present, clamped to MAX_RESPONSE_BYTES so a malicious large hint can't bypass the fix. When Content-Length is missing the Vec grows organically; the running counter still aborts the moment the next chunk would breach the cap. Worst-case allocation is now MAX_RESPONSE_BYTES + 1 chunk size, not the buffered 16 MiB ceiling regardless of Content-Length.
…llocations read_response_bytes_capped used response.bytes().await — which buffers the FULL body in memory before checking the cap. A server that omits Content-Length (chunked transfer) forces a fresh ~16 MiB allocation per request before rejection, creating real memory pressure under concurrent abuse. The audit of #3926 flagged this as a MEDIUM exploitation surface. Switch to streaming via reqwest::Response::chunk() (already in the crate's reqwest version, no extra feature flag). Loop: match response.chunk().await { Ok(Some(chunk)) if running + chunk > cap => err and abort Ok(Some(chunk)) => extend buf Ok(None) => break Err(e) => err } Pre-allocate Vec capacity from Content-Length when present, clamped to MAX_RESPONSE_BYTES so a malicious large hint can't bypass the fix. When Content-Length is missing the Vec grows organically; the running counter still aborts the moment the next chunk would breach the cap. Worst-case allocation is now MAX_RESPONSE_BYTES + 1 chunk size, not the buffered 16 MiB ceiling regardless of Content-Length.
…n stall The audit of #3926 caught two real properties of the stdio shutdown path: 1. tokio Command's kill_on_drop(true) only sends SIGKILL — it does NOT call wait(). The child becomes a zombie until tokio's internal child reaper task runs. If the reaper is itself starved (runtime spinning down) or already dropped (synchronous drop on a runtime that's been ejected), the zombie persists until the daemon process itself exits. 2. McpConnection::close() awaits client.close() unconditionally — a wedged rmcp transport loop blocks the caller forever. Hot reload (which awaits this) and daemon graceful shutdown both accumulate stalls there. A full fix for the kill_on_drop / no-wait gap requires hijacking the child handle out of rmcp's RunningService, which means either patching rmcp upstream or replacing the transport entirely — far outside follow-up scope. The contained improvement is bounding both close paths so the daemon stops betting on the tokio reaper: * Explicit close (close()): wrap client.close() in a 10s tokio::time::timeout. Healthy servers complete easily; a stuck one stops blocking hot-reload. * Implicit close (Drop): same 10s timeout inside the spawn so a wedged transport doesn't pin a tokio worker. 10s is generous enough that a normally-shutting child finishes without a warning but tight enough that 'still running 10 seconds after kill' is the right signal that something failed and we should rely on OS-level reap-on-process-exit.
…n stall (#4052) The audit of #3926 caught two real properties of the stdio shutdown path: 1. tokio Command's kill_on_drop(true) only sends SIGKILL — it does NOT call wait(). The child becomes a zombie until tokio's internal child reaper task runs. If the reaper is itself starved (runtime spinning down) or already dropped (synchronous drop on a runtime that's been ejected), the zombie persists until the daemon process itself exits. 2. McpConnection::close() awaits client.close() unconditionally — a wedged rmcp transport loop blocks the caller forever. Hot reload (which awaits this) and daemon graceful shutdown both accumulate stalls there. A full fix for the kill_on_drop / no-wait gap requires hijacking the child handle out of rmcp's RunningService, which means either patching rmcp upstream or replacing the transport entirely — far outside follow-up scope. The contained improvement is bounding both close paths so the daemon stops betting on the tokio reaper: * Explicit close (close()): wrap client.close() in a 10s tokio::time::timeout. Healthy servers complete easily; a stuck one stops blocking hot-reload. * Implicit close (Drop): same 10s timeout inside the spawn so a wedged transport doesn't pin a tokio worker. 10s is generous enough that a normally-shutting child finishes without a warning but tight enough that 'still running 10 seconds after kill' is the right signal that something failed and we should rely on OS-level reap-on-process-exit.
…llocations read_response_bytes_capped used response.bytes().await — which buffers the FULL body in memory before checking the cap. A server that omits Content-Length (chunked transfer) forces a fresh ~16 MiB allocation per request before rejection, creating real memory pressure under concurrent abuse. The audit of #3926 flagged this as a MEDIUM exploitation surface. Switch to streaming via reqwest::Response::chunk() (already in the crate's reqwest version, no extra feature flag). Loop: match response.chunk().await { Ok(Some(chunk)) if running + chunk > cap => err and abort Ok(Some(chunk)) => extend buf Ok(None) => break Err(e) => err } Pre-allocate Vec capacity from Content-Length when present, clamped to MAX_RESPONSE_BYTES so a malicious large hint can't bypass the fix. When Content-Length is missing the Vec grows organically; the running counter still aborts the moment the next chunk would breach the cap. Worst-case allocation is now MAX_RESPONSE_BYTES + 1 chunk size, not the buffered 16 MiB ceiling regardless of Content-Length.
…llocations read_response_bytes_capped used response.bytes().await — which buffers the FULL body in memory before checking the cap. A server that omits Content-Length (chunked transfer) forces a fresh ~16 MiB allocation per request before rejection, creating real memory pressure under concurrent abuse. The audit of #3926 flagged this as a MEDIUM exploitation surface. Switch to streaming via reqwest::Response::chunk() (already in the crate's reqwest version, no extra feature flag). Loop: match response.chunk().await { Ok(Some(chunk)) if running + chunk > cap => err and abort Ok(Some(chunk)) => extend buf Ok(None) => break Err(e) => err } Pre-allocate Vec capacity from Content-Length when present, clamped to MAX_RESPONSE_BYTES so a malicious large hint can't bypass the fix. When Content-Length is missing the Vec grows organically; the running counter still aborts the moment the next chunk would breach the cap. Worst-case allocation is now MAX_RESPONSE_BYTES + 1 chunk size, not the buffered 16 MiB ceiling regardless of Content-Length.
…llocations read_response_bytes_capped used response.bytes().await — which buffers the FULL body in memory before checking the cap. A server that omits Content-Length (chunked transfer) forces a fresh ~16 MiB allocation per request before rejection, creating real memory pressure under concurrent abuse. The audit of #3926 flagged this as a MEDIUM exploitation surface. Switch to streaming via reqwest::Response::chunk() (already in the crate's reqwest version, no extra feature flag). Loop: match response.chunk().await { Ok(Some(chunk)) if running + chunk > cap => err and abort Ok(Some(chunk)) => extend buf Ok(None) => break Err(e) => err } Pre-allocate Vec capacity from Content-Length when present, clamped to MAX_RESPONSE_BYTES so a malicious large hint can't bypass the fix. When Content-Length is missing the Vec grows organically; the running counter still aborts the moment the next chunk would breach the cap. Worst-case allocation is now MAX_RESPONSE_BYTES + 1 chunk size, not the buffered 16 MiB ceiling regardless of Content-Length.
…llocations read_response_bytes_capped used response.bytes().await — which buffers the FULL body in memory before checking the cap. A server that omits Content-Length (chunked transfer) forces a fresh ~16 MiB allocation per request before rejection, creating real memory pressure under concurrent abuse. The audit of #3926 flagged this as a MEDIUM exploitation surface. Switch to streaming via reqwest::Response::chunk() (already in the crate's reqwest version, no extra feature flag). Loop: match response.chunk().await { Ok(Some(chunk)) if running + chunk > cap => err and abort Ok(Some(chunk)) => extend buf Ok(None) => break Err(e) => err } Pre-allocate Vec capacity from Content-Length when present, clamped to MAX_RESPONSE_BYTES so a malicious large hint can't bypass the fix. When Content-Length is missing the Vec grows organically; the running counter still aborts the moment the next chunk would breach the cap. Worst-case allocation is now MAX_RESPONSE_BYTES + 1 chunk size, not the buffered 16 MiB ceiling regardless of Content-Length.
…llocations read_response_bytes_capped used response.bytes().await — which buffers the FULL body in memory before checking the cap. A server that omits Content-Length (chunked transfer) forces a fresh ~16 MiB allocation per request before rejection, creating real memory pressure under concurrent abuse. The audit of #3926 flagged this as a MEDIUM exploitation surface. Switch to streaming via reqwest::Response::chunk() (already in the crate's reqwest version, no extra feature flag). Loop: match response.chunk().await { Ok(Some(chunk)) if running + chunk > cap => err and abort Ok(Some(chunk)) => extend buf Ok(None) => break Err(e) => err } Pre-allocate Vec capacity from Content-Length when present, clamped to MAX_RESPONSE_BYTES so a malicious large hint can't bypass the fix. When Content-Length is missing the Vec grows organically; the running counter still aborts the moment the next chunk would breach the cap. Worst-case allocation is now MAX_RESPONSE_BYTES + 1 chunk size, not the buffered 16 MiB ceiling regardless of Content-Length.
…jection allocation) (#4051) * fix(mcp): stream MCP response body with running cap to avoid 16 MiB allocations read_response_bytes_capped used response.bytes().await — which buffers the FULL body in memory before checking the cap. A server that omits Content-Length (chunked transfer) forces a fresh ~16 MiB allocation per request before rejection, creating real memory pressure under concurrent abuse. The audit of #3926 flagged this as a MEDIUM exploitation surface. Switch to streaming via reqwest::Response::chunk() (already in the crate's reqwest version, no extra feature flag). Loop: match response.chunk().await { Ok(Some(chunk)) if running + chunk > cap => err and abort Ok(Some(chunk)) => extend buf Ok(None) => break Err(e) => err } Pre-allocate Vec capacity from Content-Length when present, clamped to MAX_RESPONSE_BYTES so a malicious large hint can't bypass the fix. When Content-Length is missing the Vec grows organically; the running counter still aborts the moment the next chunk would breach the cap. Worst-case allocation is now MAX_RESPONSE_BYTES + 1 chunk size, not the buffered 16 MiB ceiling regardless of Content-Length. * fix(mcp): compare Content-Length in u64 to keep fast-path bypass closed on 32-bit Fast-path `content_length as usize > MAX_RESPONSE_BYTES` truncates a u64 to usize on 32-bit / wasm32 targets, so a malicious Content-Length: 0x1_0000_0001 casts to 1 and slips past the "no bytes read" guarantee. The streaming loop's running counter saves us from OOM, but the fast-path semantic is lost on non-64-bit builds (notably the wasm32 sandbox tests). Compare in u64 instead. The `hint.min(MAX_RESPONSE_BYTES) as usize` reserve has the same shape — clamp in u64 before the cast for the same reason.
Summary
Drop for McpConnectionkills child process;disconnect_mcp_serverandreconcile_mcp_serverscallclose().awaitexplicitly during hot-reload (closes MCP stdio child process not killed on drop — leaks subprocess on hot reload #3800)read_response_bytes_capped()rejectsContent-Length > 16 MiBand caps streaming reads; also validatesContent-Typeheader (closes MCP SSE / HttpCompat responses read with unbounded response.text() — memory DoS #3801)tracing::debug!, capped at 256 bytes/line and 100 lines (closes MCP stdio child stderr inherited — unfiltered output to daemon stderr, fd-fill stall risk #3805)expand_env_vars: only expands vars from the server's declaredenvmap + safe system vars; unknown$VARreferences left as-is (closes MCPexpand_env_varsreads any env var into argv — leaks daemon secrets via /proc/cmdline #3823)Test plan
$SECRET_KEYin MCP command stays unexpanded