返回 CodeWhale
http_client.rs
根目录 / crates / tui / src / mcp / http_client.rs
1 //! Request-time authority for MCP transports and every OAuth HTTP operation.
2 //!
3 //! Direct public endpoints use validated DNS pins even when configured by an
4 //! operator. Explicit local endpoints/private-network opt-ins and selected
5 //! operator proxy routes carry authority only on their exact configured origin.
6 //! Model-added endpoints and server-selected secondary origins stay public.
7 //! The same client owns MCP request-time auth/header resolution for HTTP,
8 //! Streamable HTTP and SSE. OAuth retains the raw guarded request path.
9
10 use std::collections::HashMap;
11 use std::net::{IpAddr, SocketAddr};
12 use std::sync::{Arc, Mutex};
13 use std::time::Duration;
14
15 use anyhow::{Context, Result, bail};
16 use reqwest::{Method, Request, Response, Url, header};
17
18 use super::headers::{apply_safe_custom_headers, with_default_mcp_http_headers};
19 use super::{McpServerConfig, ReviewedPluginMcpSource, oauth};
20 use crate::network_policy::{Decision, NetworkPolicyDecider};
21 use crate::tools::web::guard::{guarded_reqwest_client_builder, is_restricted_ip};
22
23 #[derive(Clone, Default)]
24 pub(super) struct McpHttpAuth {
25 pub(super) server_name: String,
26 pub(super) headers: HashMap<String, String>,
27 pub(super) env_headers: HashMap<String, String>,
28 pub(super) bearer_token_env_var: Option<String>,
29 pub(super) oauth: Option<oauth::McpOAuthRuntime>,
30 /// Whether the server's *configuration* routes authentication through
31 /// OAuth, independent of whether a credential is cached yet: a URL-based
32 /// server that is neither plugin-contributed nor supplied a manual
33 /// bearer/Authorization credential (#6030).
34 ///
35 /// This is [`oauth::server_supports_oauth_login`] — the same predicate the
36 /// login flow itself is gated on — so the recovery copy it selects
37 /// (`/mcp login <name>`) names a command that will actually run. A live
38 /// [`Self::oauth`] runtime always implies it: the runtime is only built
39 /// for a server that passes this predicate. A first-run OAuth server has
40 /// no runtime yet, which is exactly the case that used to fall through to
41 /// the bearer-token copy.
42 pub(super) oauth_configured: bool,
43 pub(super) suppress_server_error_details: bool,
44 pub(super) reviewed_plugin: Option<ReviewedPluginMcpSource>,
45 }
46
47 impl McpHttpAuth {
48 pub(super) fn from_config(
49 server_name: &str,
50 config: &McpServerConfig,
51 oauth: Option<oauth::McpOAuthRuntime>,
52 ) -> Self {
53 Self {
54 server_name: server_name.to_string(),
55 headers: config.headers.clone(),
56 env_headers: config.env_headers.clone(),
57 bearer_token_env_var: config.bearer_token_env_var.clone(),
58 oauth,
59 oauth_configured: oauth::server_supports_oauth_login(config),
60 suppress_server_error_details: config.reviewed_plugin.is_some(),
61 reviewed_plugin: config.reviewed_plugin.clone(),
62 }
63 }
64
65 pub(super) fn server_error_preview(&self, preview: &str) -> String {
66 if self.suppress_server_error_details {
67 "<server details suppressed for reviewed plugin>".to_string()
68 } else {
69 preview.to_string()
70 }
71 }
72
73 pub(super) async fn resolved_headers(&self) -> Result<HashMap<String, String>> {
74 if let Some(source) = self.reviewed_plugin.as_ref() {
75 source.validate_before_use(&self.server_name, "authenticate request to")?;
76 }
77 let mut headers = self.headers.clone();
78 for (name, env_var) in &self.env_headers {
79 let value = self.reviewed_plugin.as_ref().map_or_else(
80 || std::env::var(env_var),
81 |source| source.host_environment.var(env_var),
82 );
83 if let Ok(value) = value
84 && !value.trim().is_empty()
85 {
86 headers.insert(name.clone(), value);
87 }
88 }
89 if !mcp_headers_have_authorization(&headers)
90 && let Some(env_var) = self.bearer_token_env_var.as_deref()
91 && let Ok(token) = self.reviewed_plugin.as_ref().map_or_else(
92 || std::env::var(env_var),
93 |source| source.host_environment.var(env_var),
94 )
95 {
96 let token = token.trim();
97 if !token.is_empty() {
98 headers.insert("Authorization".to_string(), format!("Bearer {token}"));
99 }
100 }
101 if !mcp_headers_have_authorization(&headers)
102 && let Some(oauth) = &self.oauth
103 {
104 let authorization = match oauth.authorization_header().await {
105 Ok(authorization) => authorization,
106 Err(_) if self.suppress_server_error_details => {
107 anyhow::bail!(
108 "Reviewed plugin MCP authentication failed (provider details suppressed)"
109 )
110 }
111 Err(error) => return Err(error),
112 };
113 if let Some(value) = authorization {
114 headers.insert("Authorization".to_string(), value);
115 }
116 }
117 Ok(headers)
118 }
119 }
120
121 pub(super) fn mcp_headers_have_authorization(headers: &HashMap<String, String>) -> bool {
122 headers
123 .keys()
124 .any(|key| key.trim().eq_ignore_ascii_case("authorization"))
125 }
126
127 #[derive(Clone)]
128 pub(crate) struct McpHttpClient {
129 origin: String,
130 operator_configured: bool,
131 private_origin_allowed: bool,
132 #[cfg(test)]
133 dns_answers: Arc<Mutex<Option<std::collections::VecDeque<Vec<SocketAddr>>>>>,
134 reviewed_plugin: bool,
135 network_policy: Option<NetworkPolicyDecider>,
136 connect_timeout: Duration,
137 read_timeout: Duration,
138 default_headers: header::HeaderMap,
139 // OAuth form bodies carry credentials independently of MCP auth headers.
140 credential_bearing: bool,
141 // Bound once after OAuth setup; clones keep the same request-time authority.
142 // Raw OAuth execute/send deliberately do not resolve this MCP auth policy.
143 mcp_auth: McpHttpAuth,
144 request_builder: reqwest::Client,
145 clients: Arc<Mutex<HashMap<String, reqwest::Client>>>,
146 }
147
148 impl McpHttpClient {
149 pub(crate) fn new(
150 url: &str,
151 runtime_added: bool,
152 reviewed_plugin: bool,
153 allow_private_network: bool,
154 network_policy: Option<&NetworkPolicyDecider>,
155 connect_timeout: Duration,
156 read_timeout: Duration,
157 ) -> Result<Self> {
158 let url = Url::parse(url).context("invalid MCP HTTP endpoint")?;
159 validate_url(&url)?;
160 validate_network_policy(&url, network_policy)?;
161 if (runtime_added || reviewed_plugin) && url_has_credentials(&url) {
162 bail!("MCP HTTP URL must not contain credentials; use configured headers");
163 }
164 Ok(Self {
165 origin: url.origin().ascii_serialization(),
166 operator_configured: !runtime_added,
167 private_origin_allowed: !runtime_added
168 && (allow_private_network || explicit_local_target(&url)),
169 #[cfg(test)]
170 dns_answers: Arc::new(Mutex::new(None)),
171 reviewed_plugin,
172 network_policy: network_policy.cloned(),
173 connect_timeout,
174 read_timeout,
175 default_headers: header::HeaderMap::new(),
176 credential_bearing: false,
177 mcp_auth: McpHttpAuth::default(),
178 request_builder: guarded_reqwest_client_builder().build()?,
179 clients: Arc::new(Mutex::new(HashMap::new())),
180 })
181 }
182
183 /// Bind the server's request-time credentials to its guarded HTTP session.
184 pub(super) fn with_mcp_auth(mut self, auth: McpHttpAuth) -> Self {
185 self.mcp_auth = auth;
186 self
187 }
188
189 pub(super) fn with_credential_transport(mut self) -> Result<Self> {
190 validate_credential_transport(&Url::parse(&self.origin)?, true)?;
191 self.credential_bearing = true;
192 Ok(self)
193 }
194
195 fn validate_request_transport(&self, request: &Request) -> Result<()> {
196 // All configured/custom headers are potentially sensitive, regardless
197 // of their names or whether an environment value is currently present.
198 // Only the transport's fixed framing headers are non-credential input.
199 let credentials = self.credential_bearing
200 || !self.mcp_auth.headers.is_empty()
201 || !self.mcp_auth.env_headers.is_empty()
202 || self.mcp_auth.bearer_token_env_var.is_some()
203 || self.mcp_auth.oauth.is_some()
204 || !self.default_headers.is_empty()
205 || request.headers().keys().any(|name| {
206 !matches!(
207 name.as_str(),
208 "accept" | "content-type" | "content-length" | "mcp-protocol-version"
209 )
210 });
211 validate_credential_transport(request.url(), credentials)
212 }
213
214 /// Prepare the MCP request's fixed framing headers and live credentials.
215 /// This remains separate from send so callers retain their existing auth,
216 /// header and long-lived-body cancellation/deadline boundaries. OAuth uses
217 /// raw execute/send and must never inherit the MCP bearer/header pass.
218 pub(crate) async fn prepare_mcp_request(
219 &self,
220 request: reqwest::RequestBuilder,
221 json_body: bool,
222 ) -> Result<reqwest::RequestBuilder> {
223 self.validate_request_transport(
224 &request
225 .try_clone()
226 .context("MCP request body cannot be replayed")?
227 .build()?,
228 )?;
229 let headers = self.mcp_auth.resolved_headers().await?;
230 Ok(apply_safe_custom_headers(
231 with_default_mcp_http_headers(request, json_body),
232 &headers,
233 ))
234 }
235
236 /// Send one exact buffered MCP request. Only an explicit 401/403 permits
237 /// one OAuth refresh and resend; transport failures and cancellation never
238 /// reach the retry arm. The caller's authority is checked around every await
239 /// and before each write. Both native Streamable HTTP and FetchProxy use it.
240 pub(crate) async fn send_mcp_request(
241 &self,
242 request: reqwest::RequestBuilder,
243 json_body: bool,
244 event_stream: bool,
245 reactive_refresh: bool,
246 mut validate: impl FnMut() -> Result<()> + Send,
247 mut observe: impl FnMut(&Response) -> Result<()>,
248 ) -> Result<Response> {
249 let mut request = request;
250 let mut retried = false;
251 loop {
252 validate()?;
253 let fresh = request
254 .try_clone()
255 .context("MCP request body cannot be replayed")?;
256 let prepared = self.prepare_mcp_request(fresh, json_body).await?;
257 validate()?;
258 let mut prepared = prepared.build()?;
259 if !event_stream {
260 prepared.timeout_mut().get_or_insert(self.read_timeout);
261 }
262 let response = self
263 .execute_with_guard(prepared, true, &mut validate)
264 .await?;
265 validate()?;
266 observe(&response)?;
267 let status = response.status();
268 if !reactive_refresh
269 || !matches!(
270 status,
271 reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
272 )
273 {
274 return Ok(response);
275 }
276 if !retried && let Some(refresh) = self.refresh_mcp_oauth().await {
277 validate()?;
278 match refresh {
279 Ok(()) => {
280 // Streamable HTTP observes session headers even on
281 // a rejected attempt, before its one authorized retry.
282 if let Some(sid) = response.headers().get("mcp-session-id") {
283 request = request.header("mcp-session-id", sid);
284 }
285 retried = true;
286 continue;
287 }
288 Err(error) => bail!(
289 "MCP server {} rejected the request with {status} and refreshing the OAuth session failed: {error:#}. {}",
290 super::mask_url_secrets(request.build()?.url().as_str()),
291 oauth::tui_reauth_refresh_failed_hint(),
292 ),
293 }
294 }
295 let hint = if self.oauth_configured() {
296 oauth::tui_reauth_hint()
297 } else {
298 "Check the configured bearer token (or its environment variable)."
299 };
300 bail!(
301 "MCP server {} rejected the request with {status}; the session is no longer accepted. {hint}",
302 super::mask_url_secrets(request.build()?.url().as_str()),
303 );
304 }
305 }
306
307 /// Only an explicit unauthorized response lets the transport request this
308 /// refresh; the session performs no replay or automatic send of its own.
309 pub(crate) async fn refresh_mcp_oauth(&self) -> Option<Result<()>> {
310 let oauth = self.mcp_auth.oauth.as_ref()?;
311 Some(oauth.force_refresh().await)
312 }
313
314 pub(crate) fn oauth_configured(&self) -> bool {
315 self.mcp_auth.oauth_configured
316 }
317
318 pub(crate) fn server_error_preview(&self, preview: &str) -> String {
319 self.mcp_auth.server_error_preview(preview)
320 }
321
322 pub(crate) fn with_default_headers(mut self, headers: header::HeaderMap) -> Self {
323 self.default_headers = headers;
324 self
325 }
326
327 pub(crate) fn get(&self, url: &str) -> reqwest::RequestBuilder {
328 self.request_builder.get(url)
329 }
330
331 pub(crate) fn post(&self, url: &str) -> reqwest::RequestBuilder {
332 self.request_builder.post(url)
333 }
334
335 pub(crate) async fn send(&self, request: reqwest::RequestBuilder) -> Result<Response> {
336 self.execute(request.build()?, true).await
337 }
338
339 /// Send the long-lived GET that carries a legacy SSE event stream.
340 ///
341 /// Response headers are still bounded by `read_timeout`, but the body is
342 /// not: a reqwest request timeout also covers body streaming, so it would
343 /// cut a healthy, quiet stream at `read_timeout` and silently drop every
344 /// later server message. The stream ending is reported through the
345 /// transport's `probe_dead`; a dead direct peer is found by TCP keepalive
346 /// (set in `client_for_target`). Through an operator HTTP(S) proxy,
347 /// keepalive only covers the hop to the proxy. MCP servers are not required
348 /// to send heartbeats, so no idle deadline can tell a quiet stream from a
349 /// dead one.
350 pub(crate) async fn send_event_stream(
351 &self,
352 request: reqwest::RequestBuilder,
353 ) -> Result<Response> {
354 self.execute_with(request.build()?, true).await
355 }
356
357 pub(crate) async fn execute(
358 &self,
359 mut request: Request,
360 follow_redirects: bool,
361 ) -> Result<Response> {
362 // Every MCP and OAuth body must finish within `read_timeout` unless the
363 // caller chose its own bound. This is a per-request timeout, not a
364 // client-wide one, so the event stream shares the same pooled client
365 // and connection as the POSTs that go with it.
366 request.timeout_mut().get_or_insert(self.read_timeout);
367 self.execute_with(request, follow_redirects).await
368 }
369
370 async fn execute_with(&self, request: Request, follow_redirects: bool) -> Result<Response> {
371 self.execute_with_guard(request, follow_redirects, &mut || Ok(()))
372 .await
373 }
374
375 async fn execute_with_guard(
376 &self,
377 mut request: Request,
378 follow_redirects: bool,
379 validate: &mut (dyn FnMut() -> Result<()> + Send),
380 ) -> Result<Response> {
381 if request.url().origin().ascii_serialization() == self.origin {
382 for (name, value) in &self.default_headers {
383 if !request.headers().contains_key(name) {
384 request.headers_mut().insert(name.clone(), value.clone());
385 }
386 }
387 }
388 let timeout = request.timeout().copied().unwrap_or(self.read_timeout);
389 tokio::time::timeout(
390 timeout,
391 self.execute_inner(request, follow_redirects, validate),
392 )
393 .await
394 .context("MCP HTTP request timed out")?
395 }
396
397 async fn execute_inner(
398 &self,
399 mut request: Request,
400 follow_redirects: bool,
401 validate: &mut (dyn FnMut() -> Result<()> + Send),
402 ) -> Result<Response> {
403 for redirect_count in 0..=5 {
404 let url = request.url().clone();
405 validate()?;
406 self.validate_request_transport(&request)?;
407 let client = self.client_for_target(&url).await?;
408 // DNS/guarded-client selection yielded. Owner generation, exact
409 // operation expiry and reviewed source must still authorize the
410 // write that follows, including every guarded redirect hop.
411 validate()?;
412 if let Some(source) = self.mcp_auth.reviewed_plugin.as_ref() {
413 source.validate_before_use(
414 &self.mcp_auth.server_name,
415 "write authenticated request to",
416 )?;
417 }
418 // MCP and OAuth requests have buffered bodies. Keep the exact request
419 // to replay only after the Location has passed the same guard.
420 let next_request = request
421 .try_clone()
422 .context("MCP request body cannot be replayed")?;
423 let response = client.execute(request).await?;
424 validate()?;
425 if !follow_redirects
426 || !matches!(response.status().as_u16(), 301 | 302 | 303 | 307 | 308)
427 {
428 return Ok(response);
429 }
430 let Some(location) = response.headers().get(header::LOCATION) else {
431 return Ok(response);
432 };
433 if redirect_count == 5 {
434 bail!("MCP HTTP redirect limit exceeded");
435 }
436 let next_url = url.join(location.to_str().context("invalid MCP redirect Location")?)?;
437 validate_url(&next_url)?;
438 if url_has_credentials(&next_url) {
439 bail!("MCP HTTP redirect must not contain credentials");
440 }
441 if url.scheme() == "https" && next_url.scheme() != "https" {
442 bail!("MCP HTTP redirect would downgrade HTTPS");
443 }
444 request = next_request;
445 if (matches!(response.status().as_u16(), 301 | 302) && request.method() == Method::POST)
446 || (response.status().as_u16() == 303 && request.method() != Method::HEAD)
447 {
448 *request.method_mut() = Method::GET;
449 *request.body_mut() = None;
450 request.headers_mut().remove(header::CONTENT_TYPE);
451 request.headers_mut().remove(header::CONTENT_LENGTH);
452 request.headers_mut().remove(header::TRANSFER_ENCODING);
453 }
454 if next_url.origin() != url.origin() {
455 // Custom headers can contain credentials under arbitrary names;
456 // retaining just Authorization/ Cookie exclusions is insufficient.
457 let mut headers = header::HeaderMap::new();
458 for name in [header::ACCEPT, header::CONTENT_TYPE] {
459 if let Some(value) = request.headers().get(&name) {
460 headers.insert(name, value.clone());
461 }
462 }
463 *request.headers_mut() = headers;
464 }
465 *request.url_mut() = next_url;
466 }
467 unreachable!("redirect loop is bounded")
468 }
469
470 async fn client_for_target(&self, url: &Url) -> Result<reqwest::Client> {
471 validate_url(url)?;
472 let same_origin = url.origin().ascii_serialization() == self.origin;
473 if self.reviewed_plugin && !super::reviewed_redirect_matches_origin(url, &self.origin) {
474 bail!("MCP redirect leaves the reviewed plugin origin");
475 }
476 validate_network_policy(url, self.network_policy.as_ref())?;
477 let operator_origin = self.operator_configured && same_origin;
478 if !operator_origin && url_has_credentials(url) {
479 bail!("MCP HTTP discovered URL must not contain credentials");
480 }
481 let proxy =
482 super::configured_mcp_proxy(url, !operator_origin || self.reviewed_plugin, |key| {
483 std::env::var(key)
484 })?;
485 // A selected operator proxy resolves its own destinations. This is
486 // delegated proxy authority, never evidence of a local DNS pin.
487 let pin = if (self.private_origin_allowed && same_origin) || proxy.is_some() {
488 None
489 } else {
490 self.public_dns_pin(url).await?
491 };
492 // Validate DNS before reusing a client too: a new private answer revokes
493 // this request. Each cached client itself remains pinned to its old public
494 // address, including reconnects after a keep-alive socket expires.
495 let key = format!("{}:{pin:?}", url.origin().ascii_serialization());
496 if proxy.is_none()
497 && let Some(client) = self
498 .clients
499 .lock()
500 .unwrap_or_else(std::sync::PoisonError::into_inner)
501 .get(&key)
502 {
503 return Ok(client.clone());
504 }
505 let mut builder = guarded_reqwest_client_builder()
506 .redirect(reqwest::redirect::Policy::none())
507 .connect_timeout(self.connect_timeout)
508 // reqwest's current default, pinned here because a long-lived SSE
509 // stream relies on it to notice a peer that vanished silently.
510 .tcp_keepalive(Duration::from_secs(15));
511 let proxied = proxy.is_some();
512 if let Some(proxy) = proxy {
513 builder = builder.proxy(proxy);
514 } else if let Some((host, address)) = pin {
515 builder = builder.resolve(&host, address);
516 }
517 let client = builder
518 .build()
519 .context("building guarded MCP HTTP client")?;
520 let mut clients = self
521 .clients
522 .lock()
523 .unwrap_or_else(std::sync::PoisonError::into_inner);
524 if !proxied && clients.len() < 32 {
525 clients.insert(key, client.clone());
526 }
527 Ok(client)
528 }
529 }
530
531 fn validate_network_policy(url: &Url, network_policy: Option<&NetworkPolicyDecider>) -> Result<()> {
532 let host = url.host_str().context("MCP URL has no host")?;
533 if let Some(policy) = network_policy {
534 match policy.evaluate(host, "mcp") {
535 Decision::Allow => {}
536 Decision::Deny => bail!("MCP HTTP destination blocked by network policy"),
537 Decision::Prompt => bail!("MCP HTTP destination requires network approval"),
538 }
539 }
540 Ok(())
541 }
542
543 fn validate_url(url: &Url) -> Result<()> {
544 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
545 bail!("MCP HTTP requires an http:// or https:// URL with a host");
546 }
547 if url_has_credentials(url) {
548 bail!("MCP HTTP URL must not contain credentials; use configured headers");
549 }
550 Ok(())
551 }
552
553 fn validate_credential_transport(url: &Url, credentials: bool) -> Result<()> {
554 validate_url(url)?;
555 if credentials && url.scheme() != "https" && !explicit_loopback_target(url) {
556 bail!("MCP HTTP credentials require HTTPS except on an explicit loopback endpoint");
557 }
558 Ok(())
559 }
560
561 fn url_has_credentials(url: &Url) -> bool {
562 !url.username().is_empty() || url.password().is_some()
563 }
564
565 impl McpHttpClient {
566 async fn public_dns_pin(&self, url: &Url) -> Result<Option<(String, SocketAddr)>> {
567 let host = url.host_str().context("MCP URL has no host")?;
568 let literal = host.trim_start_matches('[').trim_end_matches(']');
569 if let Ok(ip) = literal.parse::<IpAddr>() {
570 if is_restricted_ip(&ip) {
571 bail!("MCP HTTP destination is a restricted IP address");
572 }
573 return Ok(None);
574 }
575 let port = url.port_or_known_default().context("MCP URL has no port")?;
576 #[cfg(test)]
577 let injected = self
578 .dns_answers
579 .lock()
580 .unwrap()
581 .as_mut()
582 .map(|answers| answers.pop_front().expect("DNS fixture answer available"));
583 #[cfg(not(test))]
584 let injected: Option<Vec<SocketAddr>> = None;
585 let addresses: Vec<_> = if let Some(addresses) = injected {
586 addresses
587 } else {
588 tokio::time::timeout(self.connect_timeout, tokio::net::lookup_host((host, port)))
589 .await
590 .context("MCP HTTP DNS resolution timed out")?
591 .context("MCP HTTP DNS resolution failed")?
592 .collect()
593 };
594 let address = validated_public_address(&addresses)?;
595 Ok(Some((host.to_string(), address)))
596 }
597 }
598
599 fn explicit_local_target(url: &Url) -> bool {
600 let Some(host) = url.host_str() else {
601 return false;
602 };
603 let host = host.trim_end_matches('.');
604 host.eq_ignore_ascii_case("localhost")
605 || host.to_ascii_lowercase().ends_with(".localhost")
606 || host
607 .trim_start_matches('[')
608 .trim_end_matches(']')
609 .parse::<IpAddr>()
610 .is_ok_and(|ip| is_restricted_ip(&ip))
611 }
612
613 fn explicit_loopback_target(url: &Url) -> bool {
614 let Some(host) = url.host_str() else {
615 return false;
616 };
617 let host = host.trim_end_matches('.');
618 host.eq_ignore_ascii_case("localhost")
619 || host
620 .trim_start_matches('[')
621 .trim_end_matches(']')
622 .parse::<IpAddr>()
623 .is_ok_and(|ip| ip.is_loopback())
624 }
625
626 fn validated_public_address(addresses: &[SocketAddr]) -> Result<SocketAddr> {
627 if addresses
628 .iter()
629 .any(|address| is_restricted_ip(&address.ip()))
630 {
631 bail!("MCP HTTP DNS resolved to a restricted IP address");
632 }
633 addresses
634 .first()
635 .copied()
636 .context("MCP HTTP DNS resolved to no addresses")
637 }
638
639 #[cfg(test)]
640 mod tests {
641 use super::*;
642 use tokio::io::{AsyncReadExt, AsyncWriteExt};
643 use tokio::net::TcpListener;
644
645 fn client(url: &str, runtime_added: bool) -> McpHttpClient {
646 McpHttpClient::new(
647 url,
648 runtime_added,
649 false,
650 false,
651 None,
652 Duration::from_secs(1),
653 Duration::from_secs(2),
654 )
655 .unwrap()
656 }
657
658 async fn reply_once(listener: TcpListener, response: String) -> String {
659 let (mut socket, _) = listener.accept().await.unwrap();
660 let mut bytes = Vec::new();
661 let mut buffer = [0u8; 2048];
662 loop {
663 let n = socket.read(&mut buffer).await.unwrap();
664 assert!(n > 0);
665 bytes.extend_from_slice(&buffer[..n]);
666 if bytes.windows(4).any(|part| part == b"\r\n\r\n") {
667 break;
668 }
669 }
670 socket.write_all(response.as_bytes()).await.unwrap();
671 String::from_utf8(bytes).unwrap()
672 }
673
674 #[test]
675 fn credential_transport_requires_https_or_an_explicit_loopback_endpoint() {
676 for endpoint in [
677 "https://example.invalid/mcp",
678 "http://127.0.0.1/mcp",
679 "http://127.9.8.7/mcp",
680 "http://[::1]/mcp",
681 "http://localhost/mcp",
682 "http://localhost./mcp",
683 ] {
684 assert!(validate_credential_transport(&Url::parse(endpoint).unwrap(), true).is_ok());
685 }
686 for endpoint in [
687 "http://example.invalid/mcp",
688 "http://192.0.2.1/mcp",
689 "http://10.0.0.1/mcp",
690 "http://169.254.169.254/mcp",
691 "http://service.localhost/mcp",
692 ] {
693 let url = Url::parse(endpoint).unwrap();
694 assert!(validate_credential_transport(&url, true).is_err());
695 assert!(validate_credential_transport(&url, false).is_ok());
696 }
697 for endpoint in [
698 "https://fixture-user:fixture-password@example.invalid/mcp",
699 "http://fixture-user@127.0.0.1/mcp",
700 ] {
701 let url = Url::parse(endpoint).unwrap();
702 assert!(validate_credential_transport(&url, true).is_err());
703 assert!(validate_credential_transport(&url, false).is_err());
704 }
705 }
706
707 #[tokio::test]
708 async fn configured_mcp_credentials_refuse_remote_http_before_header_resolution() {
709 let _env = crate::test_support::lock_test_env();
710 crate::tls::ensure_rustls_crypto_provider();
711 let _token = crate::test_support::EnvVarGuard::set(
712 "CODEWHALE_TEST_MCP_TLS_BEARER",
713 "fixture-private-value",
714 );
715 let _header = crate::test_support::EnvVarGuard::set(
716 "CODEWHALE_TEST_MCP_TLS_HEADER",
717 "fixture-private-value",
718 );
719 let url = "http://mcp-guard-fixture.invalid/mcp";
720 for auth in [
721 McpHttpAuth {
722 headers: HashMap::from([(
723 "X-Arbitrary".to_string(),
724 "fixture-private-value".to_string(),
725 )]),
726 ..Default::default()
727 },
728 McpHttpAuth {
729 // Configured framing overrides are still operator-supplied input.
730 headers: HashMap::from([(
731 "Accept".to_string(),
732 "fixture-private-value".to_string(),
733 )]),
734 ..Default::default()
735 },
736 McpHttpAuth {
737 env_headers: HashMap::from([(
738 "X-Arbitrary".to_string(),
739 "CODEWHALE_TEST_MCP_TLS_HEADER".to_string(),
740 )]),
741 ..Default::default()
742 },
743 McpHttpAuth {
744 bearer_token_env_var: Some("CODEWHALE_TEST_MCP_TLS_BEARER".to_string()),
745 ..Default::default()
746 },
747 McpHttpAuth {
748 // An absent environment value cannot turn an unsafe credential
749 // configuration into an authorized transport.
750 env_headers: HashMap::from([(
751 "X-Arbitrary".to_string(),
752 "CODEWHALE_TEST_MCP_TLS_ABSENT".to_string(),
753 )]),
754 ..Default::default()
755 },
756 ] {
757 let session = client(url, false).with_mcp_auth(auth);
758 let error = session
759 .prepare_mcp_request(session.post(url).body("{}"), true)
760 .await
761 .unwrap_err();
762 assert!(error.to_string().contains("credentials require HTTPS"));
763 assert!(!error.to_string().contains("fixture-private-value"));
764 assert!(session.clients.lock().unwrap().is_empty());
765 }
766 }
767
768 #[tokio::test]
769 async fn remote_http_custom_credentials_never_reach_an_operator_proxy() {
770 let _env = crate::test_support::lock_test_env();
771 crate::tls::ensure_rustls_crypto_provider();
772 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
773 let proxy_url = format!("http://{}", listener.local_addr().unwrap());
774 let _https_proxy = crate::test_support::EnvVarGuard::set("HTTPS_PROXY", &proxy_url);
775 let _http_proxy = crate::test_support::EnvVarGuard::set("HTTP_PROXY", &proxy_url);
776 let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "");
777 let _lower_no_proxy = crate::test_support::EnvVarGuard::set("no_proxy", "");
778 let url = "http://mcp-guard-fixture.invalid/mcp";
779 let mut defaults = header::HeaderMap::new();
780 defaults.insert(
781 "X-Arbitrary",
782 header::HeaderValue::from_static("fixture-private-value"),
783 );
784 let session = client(url, false).with_default_headers(defaults);
785 let error = session
786 .send(session.post(url).body("{}"))
787 .await
788 .unwrap_err();
789 assert!(error.to_string().contains("credentials require HTTPS"));
790 assert!(!error.to_string().contains("fixture-private-value"));
791 let session = client(url, false);
792 for name in ["X-Arbitrary", "Mcp-Session-Id"] {
793 let error = session
794 .send(
795 session
796 .post(url)
797 .header(name, "fixture-private-value")
798 .body("{}"),
799 )
800 .await
801 .unwrap_err();
802 assert!(error.to_string().contains("credentials require HTTPS"));
803 }
804 assert!(
805 tokio::time::timeout(Duration::from_millis(30), listener.accept())
806 .await
807 .is_err()
808 );
809
810 // The same configured public HTTP route remains usable without credentials.
811 let server = tokio::spawn(reply_once(
812 listener,
813 "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 2\r\n\r\nok".to_string(),
814 ));
815 let response = session
816 .send_mcp_request(
817 session
818 .post(url)
819 .header("mcp-protocol-version", "2025-03-26")
820 .body("{}"),
821 true,
822 false,
823 false,
824 || Ok(()),
825 |_| Ok(()),
826 )
827 .await
828 .unwrap();
829 assert_eq!(response.text().await.unwrap(), "ok");
830 let seen = server.await.unwrap().to_ascii_lowercase();
831 assert!(seen.contains("accept: application/json, text/event-stream"));
832 assert!(!seen.contains("fixture-private-value"));
833 assert!(seen.contains("mcp-protocol-version: 2025-03-26"));
834 }
835
836 #[tokio::test]
837 async fn loopback_mcp_credentials_keep_live_auth_and_protocol_framing() {
838 let _env = crate::test_support::lock_test_env();
839 let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*");
840 crate::tls::ensure_rustls_crypto_provider();
841 let _token =
842 crate::test_support::EnvVarGuard::set("CODEWHALE_TEST_MCP_TLS_BEARER", "fixture-token");
843 let _live =
844 crate::test_support::EnvVarGuard::set("CODEWHALE_TEST_MCP_TLS_HEADER", "fixture-live");
845 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
846 let url = format!("http://{}/mcp", listener.local_addr().unwrap());
847 let server = tokio::spawn(reply_once(
848 listener,
849 "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 2\r\n\r\nok".to_string(),
850 ));
851 let session = client(&url, false).with_mcp_auth(McpHttpAuth {
852 headers: HashMap::from([("X-Arbitrary".to_string(), "fixture-static".to_string())]),
853 env_headers: HashMap::from([(
854 "X-Live".to_string(),
855 "CODEWHALE_TEST_MCP_TLS_HEADER".to_string(),
856 )]),
857 bearer_token_env_var: Some("CODEWHALE_TEST_MCP_TLS_BEARER".to_string()),
858 ..Default::default()
859 });
860 let response = session
861 .send_mcp_request(
862 session.post(&url).body("{}"),
863 true,
864 false,
865 false,
866 || Ok(()),
867 |_| Ok(()),
868 )
869 .await
870 .unwrap();
871 assert_eq!(response.text().await.unwrap(), "ok");
872 let seen = server.await.unwrap().to_ascii_lowercase();
873 assert!(seen.contains("authorization: bearer fixture-token"));
874 assert!(seen.contains("x-arbitrary: fixture-static"));
875 assert!(seen.contains("x-live: fixture-live"));
876 assert!(seen.contains("accept: application/json, text/event-stream"));
877 assert!(seen.contains("content-type: application/json"));
878 }
879
880 #[tokio::test]
881 async fn oauth_form_credentials_refuse_http_redirect_before_destination_resolution() {
882 let _env = crate::test_support::lock_test_env();
883 let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*");
884 crate::tls::ensure_rustls_crypto_provider();
885 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
886 let url = format!("http://{}/token", listener.local_addr().unwrap());
887 let server = tokio::spawn(reply_once(listener, "HTTP/1.1 307 Redirect\r\nLocation: http://mcp-guard-fixture.invalid/token\r\nConnection: close\r\nContent-Length: 0\r\n\r\n".to_string()));
888 let session = client(&url, false).with_credential_transport().unwrap();
889 *session.dns_answers.lock().unwrap() = Some(std::collections::VecDeque::from([vec![
890 "93.184.216.34:80".parse().unwrap(),
891 ]]));
892 let error = session
893 .clone()
894 .execute(
895 session
896 .post(&url)
897 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
898 .body("refresh_token=fixture-private-value")
899 .build()
900 .unwrap(),
901 true,
902 )
903 .await
904 .unwrap_err();
905 assert!(error.to_string().contains("credentials require HTTPS"));
906 assert!(!error.to_string().contains("fixture-private-value"));
907 assert_eq!(
908 session.dns_answers.lock().unwrap().as_ref().unwrap().len(),
909 1
910 );
911 assert!(server.await.unwrap().contains("POST /token"));
912 }
913
914 #[tokio::test]
915 async fn shared_mcp_send_revalidates_after_client_selection_before_first_write() {
916 let _env = crate::test_support::lock_test_env();
917 let _proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*");
918 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
919 let url = format!("http://{}/mcp", listener.local_addr().unwrap());
920 let client = client(&url, false);
921 let mut checks = 0;
922 let error = client
923 .send_mcp_request(
924 client.post(&url).body("{}"),
925 true,
926 false,
927 false,
928 || {
929 checks += 1;
930 // Before/after auth, then before/after guarded client
931 // selection. The fourth check is the actual write boundary.
932 if checks == 4 {
933 bail!("fixture owner revoked after client selection");
934 }
935 Ok(())
936 },
937 |_| Ok(()),
938 )
939 .await
940 .unwrap_err();
941 assert!(format!("{error:#}").contains("fixture owner revoked"));
942 assert_eq!(checks, 4);
943 assert!(
944 tokio::time::timeout(Duration::from_millis(30), listener.accept())
945 .await
946 .is_err()
947 );
948 }
949
950 #[tokio::test]
951 async fn shared_guard_rechecks_same_origin_redirect_before_any_second_write() {
952 let _env = crate::test_support::lock_test_env();
953 let _proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*");
954 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
955 let url = format!("http://{}/mcp", listener.local_addr().unwrap());
956 let server = tokio::spawn(async move {
957 let (mut socket, _) = listener.accept().await.unwrap();
958 let mut buffer = [0u8; 2048];
959 assert!(socket.read(&mut buffer).await.unwrap() > 0);
960 socket.write_all(b"HTTP/1.1 307 Redirect\r\nLocation: /second\r\nConnection: close\r\nContent-Length: 0\r\n\r\n").await.unwrap();
961 drop(socket);
962 tokio::time::timeout(Duration::from_millis(80), listener.accept())
963 .await
964 .is_err()
965 });
966 let client = client(&url, false);
967 let mut checks = 0;
968 let error = client
969 .execute_with_guard(
970 client.post(&url).body("{}").build().unwrap(),
971 true,
972 &mut || {
973 checks += 1;
974 if checks == 5 {
975 bail!("fixture owner revoked at redirect write boundary");
976 }
977 Ok(())
978 },
979 )
980 .await
981 .unwrap_err();
982 assert!(format!("{error:#}").contains("fixture owner revoked"));
983 assert_eq!(checks, 5);
984 assert!(server.await.unwrap(), "revoked redirect must not connect");
985 }
986
987 #[tokio::test]
988 async fn model_added_http_rejects_private_literals_and_local_dns_before_connecting() {
989 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
990 let port = listener.local_addr().unwrap().port();
991 for host in [
992 "127.0.0.1",
993 "127.1",
994 "2130706433",
995 "0x7f000001",
996 "localhost",
997 "[::1]",
998 "[::ffff:127.0.0.1]",
999 "169.254.169.254",
1000 "10.0.0.1",
1001 ] {
1002 let url = format!("http://{host}:{port}/mcp");
1003 let client = client(&url, true);
1004 for method in [Method::GET, Method::POST] {
1005 let request = client.request_builder.request(method, &url);
1006 let error = client.send(request).await.unwrap_err();
1007 assert!(
1008 format!("{error:#}").contains("restricted"),
1009 "{host}: {error:#}"
1010 );
1011 }
1012 }
1013 assert!(
1014 tokio::time::timeout(Duration::from_millis(30), listener.accept())
1015 .await
1016 .is_err()
1017 );
1018 }
1019
1020 #[test]
1021 fn mixed_dns_answers_and_empty_resolution_fail_closed() {
1022 let public = "8.8.8.8:443".parse().unwrap();
1023 for private in [
1024 "127.0.0.1:443",
1025 "10.0.0.2:443",
1026 "169.254.169.254:443",
1027 "[fc00::1]:443",
1028 ] {
1029 let private = private.parse().unwrap();
1030 assert!(validated_public_address(&[public, private]).is_err());
1031 assert!(validated_public_address(&[private, public]).is_err());
1032 }
1033 assert!(validated_public_address(&[]).is_err());
1034 assert_eq!(validated_public_address(&[public]).unwrap(), public);
1035 }
1036
1037 #[tokio::test]
1038 async fn operator_origin_remains_usable_but_does_not_authorize_private_redirects() {
1039 let _env = crate::test_support::lock_test_env();
1040 let _proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*");
1041 let destination = TcpListener::bind("127.0.0.1:0").await.unwrap();
1042 for status in [301, 302, 303, 307, 308] {
1043 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1044 let url = format!("http://{}/mcp", listener.local_addr().unwrap());
1045 let response = format!(
1046 "HTTP/1.1 {status} Redirect\r\nLocation: http://{}/private\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
1047 destination.local_addr().unwrap()
1048 );
1049 let server = tokio::spawn(reply_once(listener, response));
1050 let client = client(&url, false);
1051 let error = client
1052 .send(
1053 client
1054 .post(&url)
1055 .header("Authorization", "Bearer fixture")
1056 .body("{}"),
1057 )
1058 .await
1059 .unwrap_err();
1060 assert!(format!("{error:#}").contains("restricted"), "{error:#}");
1061 assert!(server.await.unwrap().contains("Bearer fixture"));
1062 }
1063 assert!(
1064 tokio::time::timeout(Duration::from_millis(30), destination.accept())
1065 .await
1066 .is_err()
1067 );
1068 }
1069
1070 #[tokio::test]
1071 async fn redirect_stop_returns_response_without_following_even_same_origin() {
1072 let _env = crate::test_support::lock_test_env();
1073 let _proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*");
1074 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1075 let addr = listener.local_addr().unwrap();
1076 let url = format!("http://{addr}/token");
1077 let server = tokio::spawn(async move {
1078 let (mut socket, _) = listener.accept().await.unwrap();
1079 let mut buf = [0u8; 2048];
1080 let read = socket.read(&mut buf).await.unwrap();
1081 assert!(read > 0, "fixture request must contain bytes");
1082 socket.write_all(b"HTTP/1.1 307 Redirect\r\nLocation: /capture\r\nConnection: close\r\nContent-Length: 0\r\n\r\n").await.unwrap();
1083 drop(socket);
1084 tokio::time::timeout(Duration::from_millis(80), listener.accept())
1085 .await
1086 .is_err()
1087 });
1088 let client = client(&url, false);
1089 let response = client
1090 .execute(
1091 client.post(&url).body("code=fixture").build().unwrap(),
1092 false,
1093 )
1094 .await
1095 .unwrap();
1096 assert_eq!(response.status(), 307);
1097 assert!(server.await.unwrap());
1098 }
1099
1100 #[tokio::test]
1101 async fn configured_local_same_origin_redirect_and_connection_reuse_work() {
1102 let _env = crate::test_support::lock_test_env();
1103 let _proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*");
1104 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1105 let url = format!("http://{}/mcp", listener.local_addr().unwrap());
1106 let server = tokio::spawn(async move {
1107 let (mut socket, _) = listener.accept().await.unwrap();
1108 let mut buf = [0u8; 2048];
1109 let read = socket.read(&mut buf).await.unwrap();
1110 assert!(read > 0, "fixture request must contain bytes");
1111 socket
1112 .write_all(
1113 b"HTTP/1.1 307 Redirect\r\nLocation: /mcp/v2\r\nContent-Length: 0\r\n\r\n",
1114 )
1115 .await
1116 .unwrap();
1117 let size = socket.read(&mut buf).await.unwrap();
1118 assert!(String::from_utf8_lossy(&buf[..size]).starts_with("GET /mcp/v2 "));
1119 socket
1120 .write_all(b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 2\r\n\r\nok")
1121 .await
1122 .unwrap();
1123 });
1124 let client = client(&url, false);
1125 let response = client.send(client.get(&url)).await.unwrap();
1126 assert_eq!(response.text().await.unwrap(), "ok");
1127 server.await.unwrap();
1128 }
1129
1130 #[tokio::test]
1131 async fn model_added_configuration_marker_cannot_be_spoofed_or_lost_on_clone() {
1132 let config: super::super::McpServerConfig = serde_json::from_value(serde_json::json!({
1133 "url":"http://127.0.0.1:1/mcp", "runtime_added": false, "allow_private_network": true
1134 }))
1135 .unwrap();
1136 let pool = super::super::McpPool::new(super::super::McpConfig::default());
1137 pool.add_runtime_server_config("dynamic".to_string(), config)
1138 .unwrap();
1139 let config = pool.dynamic_servers.read().get("dynamic").unwrap().clone();
1140 assert!(config.runtime_added);
1141 assert!(config.allow_private_network);
1142 let client = McpHttpClient::new(
1143 config.url.as_deref().unwrap(),
1144 config.runtime_added,
1145 false,
1146 config.allow_private_network,
1147 None,
1148 Duration::from_secs(1),
1149 Duration::from_secs(2),
1150 )
1151 .unwrap();
1152 assert!(
1153 client
1154 .client_for_target(&Url::parse(config.url.as_deref().unwrap()).unwrap())
1155 .await
1156 .is_err()
1157 );
1158 assert!(
1159 serde_json::to_value(&config)
1160 .unwrap()
1161 .get("runtime_added")
1162 .is_none()
1163 );
1164 }
1165
1166 #[tokio::test]
1167 async fn model_added_endpoint_cannot_use_ambient_proxy_but_operator_can() {
1168 let _env = crate::test_support::lock_test_env();
1169 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1170 let proxy_url = format!("http://{}", listener.local_addr().unwrap());
1171 let _https_proxy = crate::test_support::EnvVarGuard::set("HTTPS_PROXY", &proxy_url);
1172 let _http_proxy = crate::test_support::EnvVarGuard::set("HTTP_PROXY", &proxy_url);
1173 let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "");
1174 let _lower_no_proxy = crate::test_support::EnvVarGuard::set("no_proxy", "");
1175 let url = "http://mcp-guard-fixture.invalid/mcp";
1176 let strict = client(url, true);
1177 assert!(strict.send(strict.get(url)).await.is_err());
1178 // This documentation-only address passes the public-IP classifier. A
1179 // mistakenly enabled proxy would receive it without any DNS lookup.
1180 let public_literal = "http://192.0.2.1:9/mcp";
1181 let strict_literal = client(public_literal, true);
1182 assert!(
1183 strict_literal
1184 .send(strict_literal.get(public_literal))
1185 .await
1186 .is_err()
1187 );
1188 // A mistakenly enabled proxy would connect immediately; the generous
1189 // window only absorbs full-suite scheduler load.
1190 assert!(
1191 tokio::time::timeout(Duration::from_millis(500), listener.accept())
1192 .await
1193 .is_err()
1194 );
1195 let server = tokio::spawn(reply_once(
1196 listener,
1197 "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 2\r\n\r\nok".to_string(),
1198 ));
1199 // This path proves proxy routing, not a two-second scheduling bound.
1200 let configured = McpHttpClient::new(
1201 url,
1202 false,
1203 false,
1204 false,
1205 None,
1206 Duration::from_secs(5),
1207 Duration::from_secs(10),
1208 )
1209 .unwrap();
1210 assert_eq!(
1211 configured
1212 .send(configured.get(url))
1213 .await
1214 .unwrap()
1215 .text()
1216 .await
1217 .unwrap(),
1218 "ok"
1219 );
1220 assert!(
1221 server
1222 .await
1223 .unwrap()
1224 .starts_with("GET http://mcp-guard-fixture.invalid/mcp ")
1225 );
1226 }
1227
1228 #[tokio::test]
1229 async fn configured_public_origin_rejects_rebinding_before_reusing_its_pinned_client() {
1230 let _env = crate::test_support::lock_test_env();
1231 let _proxy = crate::test_support::EnvVarGuard::set("HTTPS_PROXY", "http://127.0.0.1:9");
1232 let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "mcp-guard-fixture.test");
1233 let url = Url::parse("https://mcp-guard-fixture.test/mcp").unwrap();
1234 let configured = client(url.as_str(), false);
1235 *configured.dns_answers.lock().unwrap() = Some(std::collections::VecDeque::from([
1236 vec!["8.8.8.8:443".parse().unwrap()],
1237 vec!["127.0.0.1:443".parse().unwrap()],
1238 ]));
1239 configured.client_for_target(&url).await.unwrap();
1240 assert_eq!(configured.clients.lock().unwrap().len(), 1);
1241 let error = configured.client_for_target(&url).await.unwrap_err();
1242 assert!(error.to_string().contains("restricted"), "{error:#}");
1243 assert!(
1244 configured
1245 .dns_answers
1246 .lock()
1247 .unwrap()
1248 .as_ref()
1249 .unwrap()
1250 .is_empty()
1251 );
1252 }
1253
1254 #[tokio::test]
1255 async fn private_dns_requires_an_explicit_operator_opt_in() {
1256 let _env = crate::test_support::lock_test_env();
1257 let _proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*");
1258 let url = Url::parse("https://internal-service.example.test/mcp").unwrap();
1259 let configured = client(url.as_str(), false);
1260 *configured.dns_answers.lock().unwrap() = Some(std::collections::VecDeque::from([vec![
1261 "10.0.0.3:443".parse().unwrap(),
1262 ]]));
1263 assert!(configured.client_for_target(&url).await.is_err());
1264 let approved = McpHttpClient::new(
1265 url.as_str(),
1266 false,
1267 false,
1268 true,
1269 None,
1270 Duration::from_secs(1),
1271 Duration::from_secs(2),
1272 )
1273 .unwrap();
1274 approved.client_for_target(&url).await.unwrap();
1275 }
1276
1277 #[tokio::test]
1278 async fn event_stream_shares_the_client_and_other_bodies_keep_the_read_deadline() {
1279 let _env = crate::test_support::lock_test_env();
1280 let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*");
1281 crate::tls::ensure_rustls_crypto_provider();
1282 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1283 let url = format!("http://{}/mcp", listener.local_addr().unwrap());
1284 // Every response promises 10 body bytes, sends 3 and then stalls.
1285 tokio::spawn(async move {
1286 while let Ok((mut socket, _)) = listener.accept().await {
1287 tokio::spawn(async move {
1288 let mut seen = Vec::new();
1289 let mut buffer = [0u8; 2048];
1290 while !seen.windows(4).any(|part| part == b"\r\n\r\n") {
1291 match socket.read(&mut buffer).await {
1292 Ok(n) if n > 0 => seen.extend_from_slice(&buffer[..n]),
1293 _ => return,
1294 }
1295 }
1296 let _ = socket
1297 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nabc")
1298 .await;
1299 std::future::pending::<()>().await;
1300 });
1301 }
1302 });
1303 let client = McpHttpClient::new(
1304 &url,
1305 false,
1306 false,
1307 false,
1308 None,
1309 Duration::from_secs(5),
1310 Duration::from_millis(300),
1311 )
1312 .unwrap();
1313 let stream = client.send_event_stream(client.get(&url)).await.unwrap();
1314 let response = client.send(client.post(&url)).await.unwrap();
1315 let body = tokio::time::timeout(Duration::from_secs(5), response.bytes())
1316 .await
1317 .expect("a stalled body must hit read_timeout, not hang");
1318 assert!(body.is_err());
1319 // The event stream and its POSTs share one pooled client.
1320 assert_eq!(client.clients.lock().unwrap().len(), 1);
1321 drop(stream);
1322 }
1323
1324 #[tokio::test]
1325 async fn mcp_session_clones_resolve_current_credentials_and_keep_framing() {
1326 let _env = crate::test_support::lock_test_env();
1327 crate::tls::ensure_rustls_crypto_provider();
1328 let _first_token = crate::test_support::EnvVarGuard::set(
1329 "CODEWHALE_TEST_MCP_SESSION_BEARER",
1330 "first-fixture",
1331 );
1332 let _first_header = crate::test_support::EnvVarGuard::set(
1333 "CODEWHALE_TEST_MCP_SESSION_HEADER",
1334 "first-header",
1335 );
1336 let url = "https://example.invalid/mcp";
1337 let session = client(url, false).with_mcp_auth(McpHttpAuth {
1338 headers: HashMap::from([
1339 ("Accept".to_string(), "incorrect-accept".to_string()),
1340 ("Content-Type".to_string(), "incorrect-type".to_string()),
1341 ("X-Live".to_string(), "static-header".to_string()),
1342 ("X-Unsafe".to_string(), "fixture\r\ninjected".to_string()),
1343 ]),
1344 env_headers: HashMap::from([(
1345 "X-Live".to_string(),
1346 "CODEWHALE_TEST_MCP_SESSION_HEADER".to_string(),
1347 )]),
1348 bearer_token_env_var: Some("CODEWHALE_TEST_MCP_SESSION_BEARER".to_string()),
1349 ..Default::default()
1350 });
1351 let cloned = session.clone();
1352 let first = session
1353 .prepare_mcp_request(
1354 session
1355 .post(url)
1356 .header("Mcp-Session-Id", "session-fixture"),
1357 true,
1358 )
1359 .await
1360 .unwrap()
1361 .build()
1362 .unwrap();
1363 assert_eq!(
1364 first.headers().get(header::AUTHORIZATION).unwrap(),
1365 "Bearer first-fixture"
1366 );
1367 assert_eq!(first.headers().get("X-Live").unwrap(), "first-header");
1368 assert_eq!(
1369 first.headers().get(header::ACCEPT).unwrap(),
1370 super::super::headers::MCP_HTTP_ACCEPT
1371 );
1372 assert_eq!(
1373 first.headers().get(header::CONTENT_TYPE).unwrap(),
1374 "application/json"
1375 );
1376 assert_eq!(
1377 first.headers().get("Mcp-Session-Id").unwrap(),
1378 "session-fixture"
1379 );
1380 assert!(!first.headers().contains_key("X-Unsafe"));
1381
1382 let _next_token = crate::test_support::EnvVarGuard::set(
1383 "CODEWHALE_TEST_MCP_SESSION_BEARER",
1384 "second-fixture",
1385 );
1386 let _next_header = crate::test_support::EnvVarGuard::set(
1387 "CODEWHALE_TEST_MCP_SESSION_HEADER",
1388 "second-header",
1389 );
1390 let next = cloned
1391 .prepare_mcp_request(cloned.get(url), false)
1392 .await
1393 .unwrap()
1394 .build()
1395 .unwrap();
1396 assert_eq!(
1397 next.headers().get(header::AUTHORIZATION).unwrap(),
1398 "Bearer second-fixture"
1399 );
1400 assert_eq!(next.headers().get("X-Live").unwrap(), "second-header");
1401 assert_eq!(
1402 next.headers().get(header::ACCEPT).unwrap(),
1403 super::super::headers::MCP_HTTP_ACCEPT
1404 );
1405 assert!(!next.headers().contains_key(header::CONTENT_TYPE));
1406 assert!(!next.headers().contains_key("X-Unsafe"));
1407 }
1408
1409 #[tokio::test]
1410 async fn raw_oauth_execute_does_not_inherit_bound_mcp_credentials() {
1411 let _env = crate::test_support::lock_test_env();
1412 let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*");
1413 crate::tls::ensure_rustls_crypto_provider();
1414 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1415 let origin = format!("http://{}", listener.local_addr().unwrap());
1416 let url = format!("{origin}/mcp");
1417 let server = tokio::spawn(async move {
1418 let mut seen = Vec::new();
1419 for _ in 0..2 {
1420 let (mut socket, _) =
1421 tokio::time::timeout(Duration::from_secs(5), listener.accept())
1422 .await
1423 .unwrap()
1424 .unwrap();
1425 let mut bytes = Vec::new();
1426 let mut buffer = [0u8; 2048];
1427 while !bytes.windows(4).any(|part| part == b"\r\n\r\n") {
1428 let n = socket.read(&mut buffer).await.unwrap();
1429 assert!(n > 0);
1430 bytes.extend_from_slice(&buffer[..n]);
1431 }
1432 seen.push(String::from_utf8(bytes).unwrap().to_ascii_lowercase());
1433 socket
1434 .write_all(
1435 b"HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 2\r\n\r\nok",
1436 )
1437 .await
1438 .unwrap();
1439 }
1440 seen
1441 });
1442 let session = client(&url, false).with_mcp_auth(McpHttpAuth {
1443 headers: HashMap::from([
1444 (
1445 "Authorization".to_string(),
1446 "Bearer mcp-only-fixture".to_string(),
1447 ),
1448 (
1449 "X-Mcp-Credential".to_string(),
1450 "mcp-custom-fixture".to_string(),
1451 ),
1452 ]),
1453 ..Default::default()
1454 });
1455 let request = session
1456 .prepare_mcp_request(session.post(&url), true)
1457 .await
1458 .unwrap();
1459 assert_eq!(
1460 session
1461 .send(request.body("{}"))
1462 .await
1463 .unwrap()
1464 .text()
1465 .await
1466 .unwrap(),
1467 "ok"
1468 );
1469 let token_url = format!("{origin}/token");
1470 let oauth_request = session
1471 .clone()
1472 .post(&token_url)
1473 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1474 .body("code=fixture")
1475 .build()
1476 .unwrap();
1477 assert_eq!(
1478 session
1479 .execute(oauth_request, false)
1480 .await
1481 .unwrap()
1482 .text()
1483 .await
1484 .unwrap(),
1485 "ok"
1486 );
1487 let seen = server.await.unwrap();
1488 assert!(seen[0].contains("authorization: bearer mcp-only-fixture"));
1489 assert!(seen[0].contains("x-mcp-credential: mcp-custom-fixture"));
1490 assert!(!seen[1].contains("mcp-only-fixture"));
1491 assert!(!seen[1].contains("mcp-custom-fixture"));
1492 assert!(!seen[1].contains("authorization:"));
1493 assert!(!seen[1].contains("accept: application/json, text/event-stream"));
1494 assert!(seen[1].contains("content-type: application/x-www-form-urlencoded"));
1495 }
1496 }
1497
1497 lines RUST