fix(mcp): address 14 audit findings across MCP module#1094
fix(mcp): address 14 audit findings across MCP module#1094ilblackdragon merged 2 commits intostagingfrom
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
There was a problem hiding this comment.
Pull request overview
This PR addresses multiple audit findings in the src/tools/mcp/ module, focusing on correctness and security hardening across transports, config handling, initialization, and OAuth/discovery behavior.
Changes:
- Deduplicates stdio/unix stream send logic and hardens JSON-RPC response dispatch (no-id notifications no longer resolve pending id=0).
- Improves HTTP transport SSE handling by filtering events to the matching
request_id, and strengthens localhost detection via proper URL parsing. - Refactors MCP client initialization to be concurrency-safe (
tokio::sync::OnceCell) and adjusts config persistence to use a temp-file + rename write pattern; consolidates OAuth HTTP client usage.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/tools/mcp/unix_transport.rs | Switches send path to shared stream_transport_send() helper. |
| src/tools/mcp/stdio_transport.rs | Switches send path to shared stream_transport_send() helper. |
| src/tools/mcp/transport.rs | Skips dispatch for responses without id; adds stream_transport_send() and regression test. |
| src/tools/mcp/http_transport.rs | Filters SSE events by request_id to avoid dispatching unrelated events. |
| src/tools/mcp/factory.rs | Adapts to McpClient::new_with_config() returning Result and maps errors to InvalidConfig. |
| src/tools/mcp/config.rs | Uses is_localhost_url() and changes config save to temp-write + rename; exports is_localhost_url() to crate. |
| src/tools/mcp/client.rs | Replaces AtomicBool init guard with OnceCell; changes new_with_config to return Result; updates tests. |
| src/tools/mcp/auth.rs | Introduces shared OAuth reqwest::Client and redirect debug logging; uses is_localhost_url; URL-encodes PKCE challenge. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| pub async fn initialize(&self) -> Result<InitializeResult, ToolError> { | ||
| // Fast path: already initialized (local flag or session manager) | ||
| if self.initialized.load(Ordering::Relaxed) { | ||
| // Fast path: OnceCell already completed. | ||
| if self.initialized.initialized() { | ||
| return Ok(InitializeResult::default()); | ||
| } | ||
| if let Some(ref session_manager) = self.session_manager | ||
| && session_manager.is_initialized(&self.server_name).await | ||
| { | ||
| self.initialized.store(true, Ordering::Relaxed); | ||
| return Ok(InitializeResult::default()); | ||
| } | ||
| if let Some(ref session_manager) = self.session_manager { | ||
| session_manager | ||
| .get_or_create(&self.server_name, &self.server_url) | ||
| .await; | ||
| } | ||
|
|
||
| let request = McpRequest::initialize(self.next_request_id()); | ||
| let response = self.send_request(request).await?; | ||
| // OnceCell ensures only the first concurrent caller runs the init | ||
| // closure; all others await the result. | ||
| self.initialized | ||
| .get_or_try_init(|| async { | ||
| if let Some(ref session_manager) = self.session_manager | ||
| && session_manager.is_initialized(&self.server_name).await | ||
| { | ||
| return Ok(()); | ||
| } | ||
| if let Some(ref session_manager) = self.session_manager { | ||
| session_manager | ||
| .get_or_create(&self.server_name, &self.server_url) | ||
| .await; | ||
| } | ||
|
|
||
| if let Some(error) = response.error { | ||
| return Err(ToolError::ExternalService(format!( | ||
| "MCP initialization error: {} (code {})", | ||
| error.message, error.code | ||
| ))); | ||
| } | ||
| let request = McpRequest::initialize(self.next_request_id()); | ||
| let response = self.send_request(request).await?; | ||
|
|
||
| let result: InitializeResult = response | ||
| .result | ||
| .ok_or_else(|| { | ||
| ToolError::ExternalService("No result in initialize response".to_string()) | ||
| }) | ||
| .and_then(|r| { | ||
| serde_json::from_value(r).map_err(|e| { | ||
| ToolError::ExternalService(format!("Invalid initialize result: {}", e)) | ||
| }) | ||
| })?; | ||
| if let Some(error) = response.error { | ||
| return Err(ToolError::ExternalService(format!( | ||
| "MCP initialization error: {} (code {})", | ||
| error.message, error.code | ||
| ))); | ||
| } | ||
|
|
||
| if let Some(ref session_manager) = self.session_manager { | ||
| session_manager.mark_initialized(&self.server_name).await; | ||
| } | ||
| self.initialized.store(true, Ordering::Relaxed); | ||
| let _result: InitializeResult = response | ||
| .result | ||
| .ok_or_else(|| { | ||
| ToolError::ExternalService("No result in initialize response".to_string()) | ||
| }) | ||
| .and_then(|r| { | ||
| serde_json::from_value(r).map_err(|e| { | ||
| ToolError::ExternalService(format!("Invalid initialize result: {}", e)) | ||
| }) | ||
| })?; | ||
|
|
||
| if let Some(ref session_manager) = self.session_manager { | ||
| session_manager.mark_initialized(&self.server_name).await; | ||
| } | ||
|
|
||
| let notification = McpRequest::initialized_notification(); | ||
| let _ = self.send_request(notification).await; | ||
| let notification = McpRequest::initialized_notification(); | ||
| if let Err(e) = self.send_request(notification).await { | ||
| tracing::debug!( | ||
| "Failed to send initialized notification to '{}': {}", | ||
| self.server_name, | ||
| e | ||
| ); | ||
| } | ||
|
|
||
| Ok(result) | ||
| Ok(()) | ||
| }) | ||
| .await?; | ||
|
|
||
| Ok(InitializeResult::default()) | ||
| } |
There was a problem hiding this comment.
Fixed — OnceCell<InitializeResult> now stores the parsed result and returns a clone. Subsequent calls get the cached result instead of default().
| // Write to a temporary file first, then atomically rename to avoid | ||
| // corrupting the config if the process crashes during the write. | ||
| let tmp_path = path.with_extension("json.tmp"); | ||
| fs::write(&tmp_path, content).await?; | ||
| fs::rename(&tmp_path, path).await?; |
There was a problem hiding this comment.
Acknowledged. This is a valid concern for Windows. Since IronClaw currently targets Linux/macOS (the project uses Unix sockets, systemd, launchd), this is acceptable for now. Filed as a follow-up if Windows support becomes a priority.
| .timeout(Duration::from_secs(30)) | ||
| .redirect(reqwest::redirect::Policy::none()) | ||
| .build() | ||
| .expect("Failed to create OAuth HTTP client") |
There was a problem hiding this comment.
Fixed — replaced .expect() with fallible OnceLock<Result<Client, String>> that returns AuthError::Http on build failure. All 7 call sites now use oauth_http_client()?.
- Replace panicking assert! in new_with_config with Result return (Critical) - Fix initialize() race condition using tokio::sync::OnceCell (High) - Fix localhost check bypass via proper URL parsing (High) - Extract shared stream_transport_send() to deduplicate stdio/unix send logic - Use atomic write (tmp+rename) for config file persistence - Filter SSE responses by request_id to prevent wrong-response dispatch - Share a single reqwest::Client for OAuth via fallible OnceLock - Log notification send errors instead of silently discarding - Fix unwrap_or(0) that could steal id=0 responses - Store InitializeResult in OnceCell so callers can access server capabilities - Add redirect logging in OAuth discovery - Reuse is_localhost_url() in auth.rs - Add McpToolWrapper unit tests and regression tests - URL-encode PKCE challenge for consistency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
eb3f3f9 to
c8137d6
Compare
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
zmanian
left a comment
There was a problem hiding this comment.
Security Review
Strong security PR addressing 14 audit findings. The three highest-priority items are all correctly fixed:
- assert! -> Result (Critical): No more panics on misconfigured MCP transport type.
- Race condition in initialize() (High): tokio::sync::OnceCell::get_or_try_init() is the right primitive. Clone resets the cell, correct given session manager short-circuit.
- Localhost bypass (High): Proper URL host parsing via is_localhost_url() replaces the vulnerable url.contains("localhost") check. Regression test for evil.localhost.attacker.com is exactly right.
Other notable improvements:
- SSE response filtering by request_id prevents wrong-response dispatch
- Notification id=0 stealing bug fixed with regression test
- Atomic config file writes (tmp+rename)
- Shared OAuth client with redirects disabled
10 regression tests covering the security-critical fixes. Well done.
Minor note: In stream_transport_send, let id = request.id.unwrap_or(0) after the is_none() early return is technically safe but slightly misleading. Not blocking.
* fix(mcp): address 14 audit findings across MCP module - Replace panicking assert! in new_with_config with Result return (Critical) - Fix initialize() race condition using tokio::sync::OnceCell (High) - Fix localhost check bypass via proper URL parsing (High) - Extract shared stream_transport_send() to deduplicate stdio/unix send logic - Use atomic write (tmp+rename) for config file persistence - Filter SSE responses by request_id to prevent wrong-response dispatch - Share a single reqwest::Client for OAuth via fallible OnceLock - Log notification send errors instead of silently discarding - Fix unwrap_or(0) that could steal id=0 responses - Store InitializeResult in OnceCell so callers can access server capabilities - Add redirect logging in OAuth discovery - Reuse is_localhost_url() in auth.rs - Add McpToolWrapper unit tests and regression tests - URL-encode PKCE challenge for consistency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: retrigger CI with skip-regression-check label Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(mcp): address 14 audit findings across MCP module - Replace panicking assert! in new_with_config with Result return (Critical) - Fix initialize() race condition using tokio::sync::OnceCell (High) - Fix localhost check bypass via proper URL parsing (High) - Extract shared stream_transport_send() to deduplicate stdio/unix send logic - Use atomic write (tmp+rename) for config file persistence - Filter SSE responses by request_id to prevent wrong-response dispatch - Share a single reqwest::Client for OAuth via fallible OnceLock - Log notification send errors instead of silently discarding - Fix unwrap_or(0) that could steal id=0 responses - Store InitializeResult in OnceCell so callers can access server capabilities - Add redirect logging in OAuth discovery - Reuse is_localhost_url() in auth.rs - Add McpToolWrapper unit tests and regression tests - URL-encode PKCE challenge for consistency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: retrigger CI with skip-regression-check label Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(mcp): address 14 audit findings across MCP module - Replace panicking assert! in new_with_config with Result return (Critical) - Fix initialize() race condition using tokio::sync::OnceCell (High) - Fix localhost check bypass via proper URL parsing (High) - Extract shared stream_transport_send() to deduplicate stdio/unix send logic - Use atomic write (tmp+rename) for config file persistence - Filter SSE responses by request_id to prevent wrong-response dispatch - Share a single reqwest::Client for OAuth via fallible OnceLock - Log notification send errors instead of silently discarding - Fix unwrap_or(0) that could steal id=0 responses - Store InitializeResult in OnceCell so callers can access server capabilities - Add redirect logging in OAuth discovery - Reuse is_localhost_url() in auth.rs - Add McpToolWrapper unit tests and regression tests - URL-encode PKCE challenge for consistency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: retrigger CI with skip-regression-check label Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(mcp): address 14 audit findings across MCP module - Replace panicking assert! in new_with_config with Result return (Critical) - Fix initialize() race condition using tokio::sync::OnceCell (High) - Fix localhost check bypass via proper URL parsing (High) - Extract shared stream_transport_send() to deduplicate stdio/unix send logic - Use atomic write (tmp+rename) for config file persistence - Filter SSE responses by request_id to prevent wrong-response dispatch - Share a single reqwest::Client for OAuth via fallible OnceLock - Log notification send errors instead of silently discarding - Fix unwrap_or(0) that could steal id=0 responses - Store InitializeResult in OnceCell so callers can access server capabilities - Add redirect logging in OAuth discovery - Reuse is_localhost_url() in auth.rs - Add McpToolWrapper unit tests and regression tests - URL-encode PKCE challenge for consistency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: retrigger CI with skip-regression-check label Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix(mcp): address 14 audit findings across MCP module - Replace panicking assert! in new_with_config with Result return (Critical) - Fix initialize() race condition using tokio::sync::OnceCell (High) - Fix localhost check bypass via proper URL parsing (High) - Extract shared stream_transport_send() to deduplicate stdio/unix send logic - Use atomic write (tmp+rename) for config file persistence - Filter SSE responses by request_id to prevent wrong-response dispatch - Share a single reqwest::Client for OAuth via fallible OnceLock - Log notification send errors instead of silently discarding - Fix unwrap_or(0) that could steal id=0 responses - Store InitializeResult in OnceCell so callers can access server capabilities - Add redirect logging in OAuth discovery - Reuse is_localhost_url() in auth.rs - Add McpToolWrapper unit tests and regression tests - URL-encode PKCE challenge for consistency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: retrigger CI with skip-regression-check label Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Comprehensive audit and fix of the
src/tools/mcp/module, addressing 14 findings (1 Critical, 2 High, 5 Medium, 4 Low, 1 Nit):new_with_configpanicked viaassert!on non-HTTP config — now returnsResult<Self, ToolError>initialize()(check-then-set withAtomicBool) — replaced withtokio::sync::OnceCellevil.localhost.attacker.com— proper URL host parsing viais_localhost_url()stream_transport_send()to deduplicate ~120 lines between stdio/unix transportsrequest_idto prevent wrong-response dispatchreqwest::Clientfor OAuth calls viaLazyLockunwrap_or(0)that could incorrectly resolve id=0 pending requestsClonesemantics; resetOnceCellon cloneis_localhost_url()logicifper clippymod path_routing_testsinmemory.rsTest plan
cargo fmt— cleancargo clippy --all --all-features -- -D warnings— zero warnings in MCP modulecargo test— all 3010+ tests pass (185 MCP-specific).githooks/pre-commit— passed.githooks/pre-push— passed🤖 Generated with Claude Code