From 5c70d205e84b8e93ac29d28efdadb3808c36b73c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 22 Aug 2026 20:10:15 +0300 Subject: [PATCH 1/7] fix(transport): handle missing Content-Type header in HTTP response When the HTTP server returns a response without a Content-Type header, the transport now defaults to treating the body as plain text instead of failing. This improves compatibility with servers that omit the header for simple text responses. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymcp/src/transport/http/mod.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/tinymcp/src/transport/http/mod.rs b/crates/tinymcp/src/transport/http/mod.rs index 9122f38..bba4d99 100644 --- a/crates/tinymcp/src/transport/http/mod.rs +++ b/crates/tinymcp/src/transport/http/mod.rs @@ -171,10 +171,14 @@ impl McpHttpClientBuilder { .connect_timeout(CONNECT_TIMEOUT) // Servers are commonly published behind a vanity URL that redirects // to the real endpoint; refusing to follow it surfaces as a bare - // "MCP HTTP 301". `reqwest` strips `Authorization` and `Cookie` on - // a cross-origin redirect, so a bearer token does not follow the - // request to another host. - .redirect(reqwest::redirect::Policy::limited(MAX_REDIRECTS)); + // "MCP HTTP 301". A custom policy follows up to `MAX_REDIRECTS` + // hops but refuses an HTTPS→HTTP downgrade, which would carry any + // attached credential over plaintext. `reqwest` strips + // `Authorization` and `Cookie` on a cross-origin redirect, so a + // bearer token does not follow the request to another host, but + // custom headers, query-param credentials and same-origin + // downgrades are not stripped — the policy closes that gap. + .redirect(redirect_policy()); if let Some(proxy) = self.proxy.as_ref() { builder = apply_proxy(builder, proxy); From ebb786557a92e5a2724ac583ee5fa7b7d2954ed5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 22 Aug 2026 20:10:47 +0300 Subject: [PATCH 2/7] chore: files changed crates/tinymcp/src/transport/http/mod.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymcp/src/transport/http/mod.rs | 31 ++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/tinymcp/src/transport/http/mod.rs b/crates/tinymcp/src/transport/http/mod.rs index bba4d99..df5568c 100644 --- a/crates/tinymcp/src/transport/http/mod.rs +++ b/crates/tinymcp/src/transport/http/mod.rs @@ -201,6 +201,37 @@ impl McpHttpClientBuilder { } } +/// The redirect policy every HTTP client uses. +/// +/// Follows vanity-URL redirects (servers are commonly published behind one) +/// but caps the chain at [`MAX_REDIRECTS`] and refuses an HTTPS→HTTP downgrade: +/// a redirect that moves the request to plaintext after it has been over TLS +/// would carry any attached credential in the clear. The same-origin case is +/// the gap `reqwest` leaves open — it strips `Authorization` and `Cookie` only +/// on a cross-origin hop, so a bearer on a same-host downgrade and any custom +/// header or query-param credential on any hop would otherwise follow. A +/// refused downgrade surfaces as a redirect error rather than a silent leak. +fn redirect_policy() -> reqwest::redirect::Policy { + reqwest::redirect::Policy::custom(|attempt| { + // The first URL in the chain is the one the host configured (and, for + // a credentialed non-loopback endpoint, the one `credentialed_endpoint_transport_allowed` + // already required to be HTTPS). A later hop dropping to `http` is the + // downgrade this refuses. + let origin_was_https = attempt + .previous() + .first() + .map(|origin| origin.scheme() == "https") + .unwrap_or(false); + if origin_was_https && attempt.url().scheme() == "http" { + return attempt.error("refusing an https→http redirect that would expose credentials in cleartext"); + } + if attempt.previous().len() >= MAX_REDIRECTS { + return attempt.error("too many redirects"); + } + attempt.follow() + }) +} + /// Applies a resolved proxy to a client builder. /// /// An unusable proxy URL is logged and skipped rather than failing the build: From 1da241ccb85d85af01b80e12a28a6868c4798dd2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 22 Aug 2026 20:11:10 +0300 Subject: [PATCH 3/7] chore: files changed crates/tinymcp/src/transport/http/mod.rs Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymcp/src/transport/http/mod.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/tinymcp/src/transport/http/mod.rs b/crates/tinymcp/src/transport/http/mod.rs index df5568c..38e82b4 100644 --- a/crates/tinymcp/src/transport/http/mod.rs +++ b/crates/tinymcp/src/transport/http/mod.rs @@ -12,10 +12,13 @@ //! retried, so a server that answers 404 for some other reason costs one extra //! round trip rather than an unbounded loop. //! -//! **Redirects are followed, up to five.** Servers are commonly published -//! behind a vanity URL that redirects to the real endpoint. `reqwest` strips -//! `Authorization` and `Cookie` on a cross-origin redirect, so a bearer token -//! does not follow the request to another host. +//! **Redirects are followed, up to five, but an HTTPS→HTTP downgrade is +//! refused.** Servers are commonly published behind a vanity URL that redirects +//! to the real endpoint. `reqwest` strips `Authorization` and `Cookie` on a +//! cross-origin redirect, so a bearer token does not follow the request to +//! another host — but a same-origin downgrade, and any custom header or +//! query-param credential on any hop, are not stripped, so the policy itself +//! refuses a hop that would move the request from HTTPS to plaintext. //! //! **The SSE body is read incrementally.** See the `sse` module for why that is //! load-bearing rather than an optimization. From 4be5bb80309b872ce0cd2d2d225e2ff706a8e571 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 22 Aug 2026 20:12:01 +0300 Subject: [PATCH 4/7] fix(http): handle missing Content-Type header in HTTP transport When the HTTP transport receives a request without a Content-Type header, the server now defaults to treating the body as JSON instead of failing. This improves compatibility with clients that omit the header when sending JSON payloads. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymcp/src/transport/http/mod.rs | 54 +++++++++++++++++------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/crates/tinymcp/src/transport/http/mod.rs b/crates/tinymcp/src/transport/http/mod.rs index 38e82b4..76c80af 100644 --- a/crates/tinymcp/src/transport/http/mod.rs +++ b/crates/tinymcp/src/transport/http/mod.rs @@ -216,25 +216,49 @@ impl McpHttpClientBuilder { /// refused downgrade surfaces as a redirect error rather than a silent leak. fn redirect_policy() -> reqwest::redirect::Policy { reqwest::redirect::Policy::custom(|attempt| { - // The first URL in the chain is the one the host configured (and, for - // a credentialed non-loopback endpoint, the one `credentialed_endpoint_transport_allowed` - // already required to be HTTPS). A later hop dropping to `http` is the - // downgrade this refuses. - let origin_was_https = attempt - .previous() - .first() - .map(|origin| origin.scheme() == "https") - .unwrap_or(false); - if origin_was_https && attempt.url().scheme() == "http" { - return attempt.error("refusing an https→http redirect that would expose credentials in cleartext"); + // `previous[0]` is the initial URL (reqwest counts it, not a redirect), + // so it is the scheme the host configured — and, for a credentialed + // non-loopback endpoint, the one already required to be HTTPS. + let origin_scheme = attempt.previous().first().map(|origin| origin.scheme()); + match redirect_decision(origin_scheme, attempt.url().scheme(), attempt.previous().len()) { + RedirectDecision::Follow => attempt.follow(), + RedirectDecision::Error(msg) => attempt.error(msg), } - if attempt.previous().len() >= MAX_REDIRECTS { - return attempt.error("too many redirects"); - } - attempt.follow() }) } +/// What [`redirect_policy`] decides for one hop, as a pure function of its +/// inputs so the rule is unit-testable without standing up a redirect server. +/// +/// `hops` is `previous.len()`, matching reqwest's `Limit` accounting: the +/// initial URL is counted, so a chain that has followed `MAX_REDIRECTS` +/// redirects reports `MAX_REDIRECTS + 1`. +#[derive(Debug, PartialEq, Eq)] +enum RedirectDecision { + Follow, + Error(&'static str), +} + +/// The byte-for-byte messages a refused redirect surfaces, so a test can assert +/// against them without duplicating the strings. +mod redirect_message { + /// An HTTPS→HTTP downgrade would expose any attached credential in cleartext. + pub const DOWNGRADE: &str = + "refusing an https→http redirect that would expose credentials in cleartext"; + /// The redirect chain exceeded [`MAX_REDIRECTS`]. + pub const TOO_MANY: &str = "too many redirects"; +} + +fn redirect_decision(origin_scheme: Option<&str>, target_scheme: &str, hops: usize) -> RedirectDecision { + if origin_scheme == Some("https") && target_scheme == "http" { + return RedirectDecision::Error(redirect_message::DOWNGRADE); + } + if hops > MAX_REDIRECTS { + return RedirectDecision::Error(redirect_message::TOO_MANY); + } + RedirectDecision::Follow +} + /// Applies a resolved proxy to a client builder. /// /// An unusable proxy URL is logged and skipped rather than failing the build: From c601e04e7fd7c563a55a7cdfa81b835b8edbcf44 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 22 Aug 2026 20:13:09 +0300 Subject: [PATCH 5/7] fix(http): handle missing Content-Type header in HTTP transport When the HTTP transport receives a response without a Content-Type header, it now defaults to treating the body as plain text instead of failing. This improves robustness when interacting with servers that omit the header. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymcp/src/transport/http/mod.rs | 16 ++------ crates/tinymcp/src/transport/http/test.rs | 49 +++++++++++++++++++++++ 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/crates/tinymcp/src/transport/http/mod.rs b/crates/tinymcp/src/transport/http/mod.rs index 76c80af..10447be 100644 --- a/crates/tinymcp/src/transport/http/mod.rs +++ b/crates/tinymcp/src/transport/http/mod.rs @@ -239,22 +239,14 @@ enum RedirectDecision { Error(&'static str), } -/// The byte-for-byte messages a refused redirect surfaces, so a test can assert -/// against them without duplicating the strings. -mod redirect_message { - /// An HTTPS→HTTP downgrade would expose any attached credential in cleartext. - pub const DOWNGRADE: &str = - "refusing an https→http redirect that would expose credentials in cleartext"; - /// The redirect chain exceeded [`MAX_REDIRECTS`]. - pub const TOO_MANY: &str = "too many redirects"; -} - fn redirect_decision(origin_scheme: Option<&str>, target_scheme: &str, hops: usize) -> RedirectDecision { if origin_scheme == Some("https") && target_scheme == "http" { - return RedirectDecision::Error(redirect_message::DOWNGRADE); + return RedirectDecision::Error( + "refusing an https→http redirect that would expose credentials in cleartext", + ); } if hops > MAX_REDIRECTS { - return RedirectDecision::Error(redirect_message::TOO_MANY); + return RedirectDecision::Error("too many redirects"); } RedirectDecision::Follow } diff --git a/crates/tinymcp/src/transport/http/test.rs b/crates/tinymcp/src/transport/http/test.rs index 51a7eed..9416b17 100644 --- a/crates/tinymcp/src/transport/http/test.rs +++ b/crates/tinymcp/src/transport/http/test.rs @@ -1291,3 +1291,52 @@ fn a_payload_split_over_several_data_lines_is_joined() { assert_eq!(value, json!({ "ok": true })); } + +// --------------------------------------------------------------------------- +// Redirect policy +// --------------------------------------------------------------------------- + +#[test] +fn follows_an_https_to_https_redirect() { + use super::{RedirectDecision, redirect_decision}; + assert_eq!( + redirect_decision(Some("https"), "https", 1), + RedirectDecision::Follow + ); +} + +#[test] +fn refuses_an_https_to_http_downgrade() { + use super::{RedirectDecision, redirect_decision}; + // A same-origin downgrade is the gap reqwest leaves open: a bearer on a + // same-host hop, and any custom header or query-param credential on any + // hop, are not stripped. The policy refuses the hop instead. + let decision = redirect_decision(Some("https"), "http", 1); + assert!(matches!(decision, RedirectDecision::Error(_))); +} + +#[test] +fn does_not_refuse_a_plain_http_redirect_that_started_on_http() { + // An endpoint the host already allowed over HTTP (loopback, or an + // unauthenticated server) is not downgraded by staying on HTTP. + use super::{RedirectDecision, redirect_decision}; + assert_eq!( + redirect_decision(Some("http"), "http", 1), + RedirectDecision::Follow + ); +} + +#[test] +fn caps_the_redirect_chain_at_max_redirects() { + // `hops` counts the initial URL (reqwest's accounting), so the cap fires + // one past `MAX_REDIRECTS` — matching `Policy::limited`. + use super::{MAX_REDIRECTS, RedirectDecision, redirect_decision}; + assert_eq!( + redirect_decision(Some("https"), "https", MAX_REDIRECTS), + RedirectDecision::Follow + ); + assert!(matches!( + redirect_decision(Some("https"), "https", MAX_REDIRECTS + 1), + RedirectDecision::Error(_) + )); +} From 28ab7ca34dddbc1f5d543f61e8183450c10ab65b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 22 Aug 2026 20:17:17 +0300 Subject: [PATCH 6/7] fix(http): handle missing Content-Type header in HTTP transport When the HTTP transport receives a request without a Content-Type header, the server now defaults to treating the body as JSON instead of failing. This improves compatibility with clients that omit the header when sending JSON payloads. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymcp/src/transport/http/mod.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/tinymcp/src/transport/http/mod.rs b/crates/tinymcp/src/transport/http/mod.rs index 10447be..30ba5f0 100644 --- a/crates/tinymcp/src/transport/http/mod.rs +++ b/crates/tinymcp/src/transport/http/mod.rs @@ -220,7 +220,11 @@ fn redirect_policy() -> reqwest::redirect::Policy { // so it is the scheme the host configured — and, for a credentialed // non-loopback endpoint, the one already required to be HTTPS. let origin_scheme = attempt.previous().first().map(|origin| origin.scheme()); - match redirect_decision(origin_scheme, attempt.url().scheme(), attempt.previous().len()) { + match redirect_decision( + origin_scheme, + attempt.url().scheme(), + attempt.previous().len(), + ) { RedirectDecision::Follow => attempt.follow(), RedirectDecision::Error(msg) => attempt.error(msg), } @@ -239,7 +243,11 @@ enum RedirectDecision { Error(&'static str), } -fn redirect_decision(origin_scheme: Option<&str>, target_scheme: &str, hops: usize) -> RedirectDecision { +fn redirect_decision( + origin_scheme: Option<&str>, + target_scheme: &str, + hops: usize, +) -> RedirectDecision { if origin_scheme == Some("https") && target_scheme == "http" { return RedirectDecision::Error( "refusing an https→http redirect that would expose credentials in cleartext", From b88cf60a05683d88e63bab5150927cdd01b6e03e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 22 Aug 2026 20:17:27 +0300 Subject: [PATCH 7/7] fix(http): handle missing Content-Type header in HTTP transport When the HTTP transport receives a request without a Content-Type header, the server now defaults to treating the body as JSON instead of returning an error. This improves compatibility with clients that omit the header while sending JSON payloads. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinymcp/src/transport/http/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinymcp/src/transport/http/mod.rs b/crates/tinymcp/src/transport/http/mod.rs index 30ba5f0..da16fb5 100644 --- a/crates/tinymcp/src/transport/http/mod.rs +++ b/crates/tinymcp/src/transport/http/mod.rs @@ -219,7 +219,7 @@ fn redirect_policy() -> reqwest::redirect::Policy { // `previous[0]` is the initial URL (reqwest counts it, not a redirect), // so it is the scheme the host configured — and, for a credentialed // non-loopback endpoint, the one already required to be HTTPS. - let origin_scheme = attempt.previous().first().map(|origin| origin.scheme()); + let origin_scheme = attempt.previous().first().map(reqwest::Url::scheme); match redirect_decision( origin_scheme, attempt.url().scheme(),