use super::types::*;
#[cfg(feature = "native")]
use async_trait::async_trait;
use futures::StreamExt;
use std::collections::BTreeMap;
#[cfg(feature = "native")]
use std::collections::HashMap;
use std::sync::Arc;
#[cfg(feature = "native")]
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
#[cfg(feature = "native")]
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
use tracing::{debug, warn};
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
pub trait McpTransport: crate::rt::MaybeSend + crate::rt::MaybeSync {
async fn send(&self, request: JsonRpcRequest) -> Result<JsonRpcResponse, McpError>;
async fn notify(&self, notification: JsonRpcNotification) -> Result<(), McpError> {
let method = notification.method.clone();
if let Err(e) = self
.send(JsonRpcRequest::new(
notification.method,
notification.params,
))
.await
{
warn!("MCP notification '{method}' through send() failed: {e}");
}
Ok(())
}
async fn close(&self) -> Result<(), McpError>;
fn report_notifications(&mut self, to: ServerNotifications) -> bool {
let _ = to;
false
}
fn exit_watch(&self) -> Option<ExitWatch> {
None
}
}
#[derive(Clone)]
pub struct ExitWatch {
check: std::sync::Arc<dyn Fn() -> Option<String> + Send + Sync>,
}
impl std::fmt::Debug for ExitWatch {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExitWatch").finish_non_exhaustive()
}
}
impl ExitWatch {
pub fn new(check: impl Fn() -> Option<String> + Send + Sync + 'static) -> Self {
Self {
check: std::sync::Arc::new(check),
}
}
pub fn exited(&self) -> Option<String> {
(self.check)()
}
}
const TOOLS_LIST_CHANGED: &str = "notifications/tools/list_changed";
#[derive(Clone, Debug, Default)]
pub struct ServerNotifications {
tools_list_changed: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
impl ServerNotifications {
pub fn new() -> Self {
Self::default()
}
pub fn record(&self, method: &str) {
if method == TOOLS_LIST_CHANGED {
self.tools_list_changed
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
debug!("MCP server: its tool list changed");
}
}
pub fn tools_list_changed(&self) -> u64 {
self.tools_list_changed
.load(std::sync::atomic::Ordering::SeqCst)
}
}
fn note_notification(method: &str, to: Option<&ServerNotifications>) {
if let Some(to) = to {
to.record(method);
}
}
#[cfg(feature = "native")]
#[cfg_attr(docsrs, doc(cfg(feature = "native")))]
pub struct StdioTransport {
stdin: Arc<Mutex<StdinWriter>>,
stdout: Arc<Mutex<BufReader<tokio::process::ChildStdout>>>,
child: Arc<Mutex<Child>>,
stderr_tail: Arc<std::sync::Mutex<StderrTail>>,
stderr_drain: Mutex<Option<tokio::task::JoinHandle<()>>>,
command: String,
notifications: Option<ServerNotifications>,
ended: Arc<Ended>,
group: Arc<std::sync::Mutex<Option<u32>>>,
}
#[cfg(feature = "native")]
#[cfg_attr(docsrs, doc(cfg(feature = "native")))]
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct StdioOptions {
own_process_group: bool,
}
#[cfg(feature = "native")]
impl StdioOptions {
pub fn new() -> Self {
Self::default()
}
pub fn own_process_group(mut self, yes: bool) -> Self {
self.own_process_group = yes;
self
}
}
#[cfg(feature = "native")]
#[derive(Default)]
struct Ended {
output_closed: std::sync::atomic::AtomicBool,
input_closed: std::sync::atomic::AtomicBool,
closed_by_host: std::sync::atomic::AtomicBool,
stderr_done: std::sync::atomic::AtomicBool,
}
#[cfg(feature = "native")]
const EXIT_TAIL_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
#[cfg(feature = "native")]
struct StdinWriter {
stdin: tokio::process::ChildStdin,
interrupted: bool,
}
#[cfg(feature = "native")]
#[derive(Default)]
struct StderrTail {
lines: std::collections::VecDeque<String>,
bytes: usize,
}
#[cfg(feature = "native")]
impl StderrTail {
const MAX_BYTES: usize = 4096;
fn push(&mut self, line: String) {
self.bytes += line.len();
self.lines.push_back(line);
while self.bytes > Self::MAX_BYTES && self.lines.len() > 1 {
if let Some(old) = self.lines.pop_front() {
self.bytes -= old.len();
}
}
}
fn render(&self) -> String {
self.lines.iter().cloned().collect::<Vec<_>>().join("\n")
}
}
#[cfg(feature = "native")]
impl StdioTransport {
pub async fn new(
command: &str,
args: &[&str],
env: Option<HashMap<String, String>>,
) -> Result<Self, McpError> {
Self::with_options(command, args, env, StdioOptions::default()).await
}
pub async fn with_options(
command: &str,
args: &[&str],
env: Option<HashMap<String, String>>,
options: StdioOptions,
) -> Result<Self, McpError> {
#[cfg_attr(not(unix), allow(unused_mut))]
let mut cmd = std::process::Command::new(command);
#[cfg(unix)]
if options.own_process_group {
std::os::unix::process::CommandExt::process_group(&mut cmd, 0);
}
let mut cmd = Command::from(cmd);
cmd.args(args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
if let Some(env_vars) = env {
for (k, v) in env_vars {
cmd.env(k, v);
}
}
let mut child = cmd
.spawn()
.map_err(|e| McpError::Transport(format!("Failed to spawn '{}': {}", command, e)))?;
let group = if cfg!(unix) && options.own_process_group {
child.id()
} else {
None
};
let stdin = child
.stdin
.take()
.ok_or_else(|| McpError::Transport("Failed to capture stdin".into()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| McpError::Transport("Failed to capture stdout".into()))?;
let stderr_tail = Arc::new(std::sync::Mutex::new(StderrTail::default()));
let ended = Arc::new(Ended::default());
let stderr_drain = match child.stderr.take() {
Some(stderr) => {
let (server, tail, ended) =
(command.to_string(), stderr_tail.clone(), ended.clone());
Some(tokio::spawn(async move {
drain_stderr(stderr, server, tail).await;
ended
.stderr_done
.store(true, std::sync::atomic::Ordering::SeqCst);
}))
}
None => {
ended
.stderr_done
.store(true, std::sync::atomic::Ordering::SeqCst);
None
}
};
Ok(Self {
stdin: Arc::new(Mutex::new(StdinWriter {
stdin,
interrupted: false,
})),
stdout: Arc::new(Mutex::new(BufReader::new(stdout))),
child: Arc::new(Mutex::new(child)),
stderr_tail,
stderr_drain: Mutex::new(stderr_drain),
command: command.to_string(),
notifications: None,
ended,
group: Arc::new(std::sync::Mutex::new(group)),
})
}
fn kill_group(&self, child: &mut Child) {
kill_group(&self.group, child, &self.command);
}
async fn write_message(&self, json: &str) -> Result<(), McpError> {
let mut writer = self.stdin.lock().await;
let message = if writer.interrupted {
format!("\n{json}\n")
} else {
format!("{json}\n")
};
writer.interrupted = true; let input_closed = |what: &str, e: std::io::Error| {
self.ended
.input_closed
.store(true, std::sync::atomic::Ordering::SeqCst);
McpError::Transport(format!("{what} error: {e}"))
};
writer
.stdin
.write_all(message.as_bytes())
.await
.map_err(|e| input_closed("Write", e))?;
writer
.stdin
.flush()
.await
.map_err(|e| input_closed("Flush", e))?;
writer.interrupted = false;
Ok(())
}
async fn closed_error(&self, skipped: &str) -> McpError {
let drain = self.stderr_drain.lock().await.take();
if let Some(drain) = drain {
let _ = tokio::time::timeout(std::time::Duration::from_millis(500), drain).await;
}
let status = {
let mut child = self.child.lock().await;
let own_group = self
.group
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some();
let exited = if own_group
&& self
.ended
.output_closed
.load(std::sync::atomic::Ordering::SeqCst)
{
match settle_group(&self.group, &mut child, &self.command) {
Some(false) => None,
_ => tokio::time::timeout(std::time::Duration::from_millis(500), child.wait())
.await
.ok()
.and_then(Result::ok),
}
} else if own_group {
let started = std::time::Instant::now();
let mut running = true;
while started.elapsed() < std::time::Duration::from_millis(500) {
if settle_group(&self.group, &mut child, &self.command) != Some(false) {
running = false;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
if running {
None
} else {
tokio::time::timeout(std::time::Duration::from_millis(500), child.wait())
.await
.ok()
.and_then(Result::ok)
}
} else {
tokio::time::timeout(std::time::Duration::from_millis(500), child.wait())
.await
.ok()
.and_then(Result::ok)
};
match exited {
Some(status) => format!("exited with {status}"),
None => {
self.ended
.output_closed
.store(true, std::sync::atomic::Ordering::SeqCst);
"closed its output".to_string()
}
}
};
let tail = stderr_suffix(&self.stderr_tail);
McpError::Transport(format!(
"Connection closed: MCP server '{}' {status}{skipped}{tail}",
self.command
))
}
}
#[cfg(feature = "native")]
fn kill_group(group: &std::sync::Mutex<Option<u32>>, child: &mut Child, command: &str) {
let Some(group) = group.lock().unwrap_or_else(|e| e.into_inner()).take() else {
return;
};
let Some(pid) = child.id() else {
debug!(
"MCP server '{command}': its command already exited and was collected; its process group is left alone"
);
return;
};
#[cfg(unix)]
if let (Ok(pgid), Some(_)) = (libc::pid_t::try_from(group), leader_state(pid)) {
if unsafe { libc::killpg(pgid, libc::SIGKILL) } == -1 {
let e = std::io::Error::last_os_error();
if e.raw_os_error() != Some(libc::ESRCH) {
debug!("MCP server '{command}': killing its process group: {e}");
}
}
} else {
debug!("MCP server '{command}': its command is no longer ours to signal; its process group is left alone");
}
#[cfg(not(unix))]
let _ = (group, pid);
}
#[cfg(all(feature = "native", unix))]
fn leader_state(pid: u32) -> Option<bool> {
let pid = libc::id_t::try_from(pid).ok()?;
unsafe {
let mut info: libc::siginfo_t = std::mem::zeroed();
let ok = libc::waitid(
libc::P_PID,
pid,
&mut info,
libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
) == 0;
ok.then_some(info.si_signo == libc::SIGCHLD)
}
}
#[cfg(feature = "native")]
fn settle_group(
group: &std::sync::Mutex<Option<u32>>,
child: &mut Child,
command: &str,
) -> Option<bool> {
if group.lock().unwrap_or_else(|e| e.into_inner()).is_none() {
return None;
}
#[cfg(unix)]
{
let exited = child.id().and_then(leader_state)?;
if exited {
kill_group(group, child, command);
}
Some(exited)
}
#[cfg(not(unix))]
{
let _ = (child, command);
None
}
}
#[cfg(feature = "native")]
fn stderr_suffix(tail: &std::sync::Mutex<StderrTail>) -> String {
let tail = tail.lock().map(|t| t.render()).unwrap_or_default();
if tail.trim().is_empty() {
String::new()
} else {
format!("; last stderr:\n{tail}")
}
}
#[cfg(feature = "native")]
impl Drop for StdioTransport {
fn drop(&mut self) {
if self
.group
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_none()
{
return;
}
for _ in 0..1000 {
if let Ok(mut child) = self.child.try_lock() {
self.kill_group(&mut child);
return;
}
std::thread::yield_now();
}
warn!(
"MCP server '{}': its process stayed locked at drop; its process group was not \
killed (what it started may still run)",
self.command
);
}
}
#[cfg(feature = "native")]
async fn drain_stderr(
stderr: tokio::process::ChildStderr,
server: String,
tail: Arc<std::sync::Mutex<StderrTail>>,
) {
use tokio::io::AsyncReadExt;
let mut reader = BufReader::new(stderr);
let mut buf = Vec::new();
loop {
buf.clear();
match (&mut reader).take(8192).read_until(b'\n', &mut buf).await {
Ok(0) => return,
Ok(_) => {
let line = String::from_utf8_lossy(&buf).trim_end().to_string();
if line.is_empty() {
continue;
}
debug!(target: "yoagent::mcp::stderr", server = %server, "{line}");
if let Ok(mut tail) = tail.lock() {
tail.push(line);
}
}
Err(e) => {
warn!("MCP server '{server}': reading its stderr failed ({e}); it may block if it keeps writing");
return;
}
}
}
}
#[cfg(feature = "native")]
fn server_request_reply(method: &str, id: serde_json::Value) -> serde_json::Value {
if method == "ping" {
serde_json::json!({"jsonrpc": "2.0", "id": id, "result": {}})
} else {
warn!("MCP server asked for '{method}', which this client does not support; declining");
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"error": {"code": -32601, "message": format!("method not supported by this client: {method}")}
})
}
}
#[cfg(feature = "native")]
fn response_id(value: &serde_json::Value) -> Option<u64> {
match value.get("id")? {
serde_json::Value::Number(n) => n.as_u64(),
serde_json::Value::String(s) => s.parse().ok(),
_ => None,
}
}
#[cfg(feature = "native")]
#[async_trait]
impl McpTransport for StdioTransport {
async fn send(&self, request: JsonRpcRequest) -> Result<JsonRpcResponse, McpError> {
let request_id = request.id;
let method = request.method.clone();
self.write_message(&serde_json::to_string(&request)?)
.await?;
let mut stdout = self.stdout.lock().await;
let mut non_json = 0usize;
let mut last_non_json = String::new();
loop {
let mut line = String::new();
let bytes_read = stdout
.read_line(&mut line)
.await
.map_err(|e| McpError::Transport(format!("Read error: {}", e)))?;
if bytes_read == 0 {
let skipped = if non_json > 0 {
format!(
" ({non_json} non-JSON line(s) on stdout skipped; the last began: {last_non_json:?})"
)
} else {
String::new()
};
return Err(self.closed_error(&skipped).await);
}
let line = line.trim();
if line.is_empty() {
continue;
}
let value: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(e) => {
non_json += 1;
last_non_json = line.chars().take(80).collect();
warn!(
"MCP server '{}': skipping a non-JSON line on stdout during '{method}' ({} bytes, {e}): {last_non_json:?}",
self.command,
line.len()
);
continue;
}
};
if let Some(server_method) = value.get("method").and_then(|m| m.as_str()) {
match value.get("id").cloned() {
Some(id) => {
let reply = server_request_reply(server_method, id);
self.write_message(&reply.to_string()).await?;
}
None => {
debug!("MCP stdio: server notification '{server_method}'");
note_notification(server_method, self.notifications.as_ref());
}
}
continue;
}
match response_id(&value) {
Some(id) if id == request_id => return Ok(serde_json::from_value(value)?),
None if value.get("error").is_some() => {
warn!(
"MCP server '{}': an error without an id arrived during '{method}'; reporting it for this call",
self.command
);
return Ok(serde_json::from_value(value)?);
}
other => debug!("MCP stdio: dropping a response for id {other:?} on '{method}'"),
}
}
}
async fn notify(&self, notification: JsonRpcNotification) -> Result<(), McpError> {
self.write_message(&serde_json::to_string(¬ification)?)
.await
}
fn report_notifications(&mut self, to: ServerNotifications) -> bool {
self.notifications = Some(to);
true
}
fn exit_watch(&self) -> Option<ExitWatch> {
let child = Arc::downgrade(&self.child);
let (tail, ended, command, group) = (
self.stderr_tail.clone(),
self.ended.clone(),
self.command.clone(),
self.group.clone(),
);
let first_seen = std::sync::Mutex::new(None::<std::time::Instant>);
let provisional = std::sync::Mutex::new(None::<String>);
let latched = std::sync::Mutex::new(None::<String>);
Some(ExitWatch::new(move || {
use std::sync::atomic::Ordering::SeqCst;
let mut latched = latched.lock().unwrap_or_else(|p| p.into_inner());
if group.lock().unwrap_or_else(|p| p.into_inner()).is_some() {
if let Some(child) = child.upgrade() {
if let Ok(mut child) = child.try_lock() {
let _ = settle_group(&group, &mut child, &command);
}
}
}
if let Some(why) = latched.as_ref() {
return Some(why.clone());
}
let mut provisional = provisional.lock().unwrap_or_else(|p| p.into_inner());
let why = if ended.closed_by_host.load(SeqCst) {
Some(format!("MCP server '{command}' was closed (`close`)"))
} else if let Some(child) = child.upgrade() {
match child.try_lock() {
Err(_) => provisional.clone(),
Ok(mut child) => {
let state = match settle_group(&group, &mut child, &command) {
Some(false) => Ok(None),
_ => child.try_wait(),
};
match state {
Ok(Some(status)) => {
Some(format!("MCP server '{command}' exited with {status}"))
}
Ok(None) if ended.output_closed.load(SeqCst) => Some(format!(
"MCP server '{command}' closed its output (its process is still running)"
)),
Ok(None) if ended.input_closed.load(SeqCst) => Some(format!(
"MCP server '{command}' no longer reads its input (its process is still running)"
)),
Ok(None) => None,
Err(e) => Some(format!(
"MCP server '{command}' has gone (exit status unknown: {e})"
)),
}
}
}
} else {
let why = format!("MCP server '{command}' was closed: its client was dropped");
*latched = Some(why.clone());
return Some(why);
};
let why = why?;
*provisional = Some(why.clone());
let done = ended.stderr_done.load(SeqCst);
let why = format!("{why}{}", stderr_suffix(&tail));
let first = *first_seen
.lock()
.unwrap_or_else(|p| p.into_inner())
.get_or_insert_with(std::time::Instant::now);
if done || first.elapsed() >= EXIT_TAIL_GRACE {
*latched = Some(why.clone());
}
Some(why)
}))
}
async fn close(&self) -> Result<(), McpError> {
self.ended
.closed_by_host
.store(true, std::sync::atomic::Ordering::SeqCst);
let mut child = self.child.lock().await;
self.kill_group(&mut child);
if let Err(e) = child.kill().await {
debug!("MCP server '{}': kill on close: {e}", self.command);
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum Redirects {
None,
#[default]
SameOrigin,
Any,
}
#[cfg(not(target_arch = "wasm32"))]
fn redirect_policy(redirects: Redirects) -> reqwest::redirect::Policy {
use reqwest::redirect::Policy;
match redirects {
Redirects::None => Policy::custom(|attempt| {
let to = url_origin(attempt.url());
attempt.error(RedirectRefused(format!(
"the server redirected (to {to}); redirects are off (HttpTransport::with_redirects)"
)))
}),
Redirects::SameOrigin => Policy::custom(|attempt| {
if attempt.previous().len() > 10 {
return attempt.error(RedirectRefused("more than 10 redirects".into()));
}
let Some(origin) = attempt.previous().first().map(|from| from.origin()) else {
return attempt.follow();
};
if attempt.url().origin() == origin {
attempt.follow()
} else {
let to = url_origin(attempt.url());
let from = origin.ascii_serialization();
attempt.error(RedirectRefused(format!(
"the server redirected to {to}, outside {from}; refused, so the \
transport's headers do not go there (HttpTransport::with_redirects)"
)))
}
}),
Redirects::Any => Policy::limited(10),
}
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug)]
struct RedirectRefused(String);
#[cfg(not(target_arch = "wasm32"))]
impl std::fmt::Display for RedirectRefused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[cfg(not(target_arch = "wasm32"))]
impl std::error::Error for RedirectRefused {}
fn url_origin(url: &reqwest::Url) -> String {
url.origin().ascii_serialization()
}
fn named_origin(url: &str) -> String {
reqwest::Url::parse(url)
.map(|u| url_origin(&u))
.unwrap_or_else(|_| "the server".into())
}
#[derive(Default, Clone)]
struct UrlSecrets(Vec<String>);
impl UrlSecrets {
const MIN_PIECE: usize = 8;
fn of(url: &str) -> Self {
let Ok(url) = reqwest::Url::parse(url) else {
return Self::default();
};
let mut all = Vec::new();
let mut part = |whole: &str, pieces: Vec<String>| {
let pieces: Vec<String> = pieces
.iter()
.flat_map(|p| [p.clone(), percent_decoded(p)])
.filter(|p| p.chars().count() >= Self::MIN_PIECE)
.collect();
if !pieces.is_empty() {
all.push(whole.to_owned());
all.extend(pieces);
}
};
part(
url.path(),
url.path().split('/').map(str::to_owned).collect(),
);
if let Some(query) = url.query() {
let mut pieces = Vec::new();
for pair in query.split('&') {
pieces.push(pair.to_owned());
if let Some((_, value)) = pair.split_once('=') {
pieces.push(value.to_owned());
}
}
pieces.extend(url.query_pairs().map(|(_, value)| value.into_owned()));
part(query, pieces);
}
part(url.username(), vec![url.username().to_owned()]);
if let Some(password) = url.password() {
part(password, vec![password.to_owned()]);
}
all.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
all.dedup();
Self(all)
}
fn with_values<'a>(&self, values: impl IntoIterator<Item = &'a str>) -> Self {
let mut all = self.0.clone();
for value in values {
let credential = value.split_once(' ').map(|(_, c)| c.trim());
for piece in [Some(value.trim()), credential].into_iter().flatten() {
if piece.chars().count() >= Self::MIN_PIECE {
all.push(piece.to_owned());
}
}
}
all.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
all.dedup();
Self(all)
}
fn scrub(&self, text: &str) -> String {
let mut text = text.to_owned();
for secret in &self.0 {
if text.contains(secret.as_str()) {
text = text.replace(secret.as_str(), "[redacted]");
}
}
text
}
fn excerpt(&self, body: &str) -> String {
self.scrub(body.trim()).chars().take(200).collect()
}
}
fn percent_decoded(raw: &str) -> String {
let bytes = raw.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let hex = |b: u8| (b as char).to_digit(16);
match (
bytes[i],
bytes.get(i + 1).copied().and_then(hex),
bytes.get(i + 2).copied().and_then(hex),
) {
(b'%', Some(hi), Some(lo)) => {
out.push((hi * 16 + lo) as u8);
i += 3;
}
(b, _, _) => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[derive(Clone)]
#[non_exhaustive]
pub struct AuthChallenge {
pub status: u16,
pub www_authenticate: Option<String>,
pub method: String,
refused: Option<String>,
}
impl AuthChallenge {
pub fn new(status: u16, method: impl Into<String>) -> Self {
Self {
status,
www_authenticate: None,
method: method.into(),
refused: None,
}
}
pub fn with_www_authenticate(mut self, challenge: impl Into<String>) -> Self {
self.www_authenticate = Some(challenge.into());
self
}
pub fn with_refused(mut self, value: impl Into<String>) -> Self {
self.refused = Some(value.into());
self
}
pub fn refused(&self) -> Option<&str> {
self.refused.as_deref()
}
}
impl std::fmt::Debug for AuthChallenge {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthChallenge")
.field("status", &self.status)
.field("www_authenticate", &self.www_authenticate)
.field("method", &self.method)
.field("refused", &self.refused.as_ref().map(|_| "[redacted]"))
.finish()
}
}
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
pub trait AuthorizationSource: crate::rt::MaybeSend + crate::rt::MaybeSync {
async fn authorization(&self) -> Result<Option<String>, McpError>;
async fn challenged(&self, challenge: &AuthChallenge) -> Result<Option<String>, McpError> {
let _ = challenge;
Ok(None)
}
}
fn is_credential_header(name: &reqwest::header::HeaderName) -> bool {
let name = name.as_str();
matches!(name, "authorization" | "proxy-authorization" | "cookie")
|| ["key", "token", "secret", "auth", "session"]
.iter()
.any(|part| name.contains(part))
}
fn challenge_of(resp: &reqwest::Response) -> Option<String> {
let status = resp.status().as_u16();
if status != 401 && status != 403 {
return None;
}
let all: Vec<String> = resp
.headers()
.get_all(reqwest::header::WWW_AUTHENTICATE)
.iter()
.map(|v| String::from_utf8_lossy(v.as_bytes()).into_owned())
.collect();
(!all.is_empty()).then(|| all.join(", "))
}
struct Sent {
resp: reqwest::Response,
retried: bool,
}
fn http_error(context: &str, e: reqwest::Error) -> McpError {
let e = e.without_url();
let mut text = e.to_string();
if e.is_redirect() {
let mut source = std::error::Error::source(&e);
while let Some(cause) = source {
text.push_str(": ");
text.push_str(&cause.to_string());
source = cause.source();
}
}
McpError::Transport(format!("{context}: {text}"))
}
pub struct HttpTransport {
client: reqwest::Client,
base_url: String,
origin: String,
secrets: UrlSecrets,
headers: reqwest::header::HeaderMap,
authorization: Option<Arc<dyn AuthorizationSource>>,
fixed_secrets: Vec<String>,
issued: std::sync::Mutex<Vec<String>>,
session_id: Mutex<Option<String>>,
notifications: Option<ServerNotifications>,
}
impl HttpTransport {
#[cfg(not(target_arch = "wasm32"))]
const READ_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
pub fn new(url: &str) -> Result<Self, McpError> {
let client = Self::client_for(Redirects::default())?;
Ok(Self {
client,
base_url: url.trim_end_matches('/').to_string(),
origin: named_origin(url),
secrets: UrlSecrets::of(url.trim_end_matches('/')),
headers: reqwest::header::HeaderMap::new(),
authorization: None,
fixed_secrets: Vec::new(),
issued: std::sync::Mutex::new(Vec::new()),
session_id: Mutex::new(None),
notifications: None,
})
}
async fn expired_session(&self, status: reqwest::StatusCode, method: &str) -> Option<McpError> {
if status != reqwest::StatusCode::NOT_FOUND {
return None;
}
self.session_id.lock().await.take()?;
warn!("MCP session was rejected (HTTP 404 on '{method}'); reconnect to start a new one");
Some(McpError::http(
404,
format!(
"MCP session expired (HTTP 404 on '{method}'); reconnect to start a new session"
),
))
}
fn client_for(redirects: Redirects) -> Result<reqwest::Client, McpError> {
let builder = reqwest::Client::builder();
#[cfg(not(target_arch = "wasm32"))]
let builder = builder
.read_timeout(Self::READ_IDLE_TIMEOUT)
.redirect(redirect_policy(redirects));
#[cfg(target_arch = "wasm32")]
let _ = redirects;
builder
.build()
.map_err(|e| McpError::Transport(format!("Failed to build HTTP client: {e}")))
}
pub fn with_redirects(mut self, redirects: Redirects) -> Result<Self, McpError> {
self.client = Self::client_for(redirects)?;
Ok(self)
}
const RESERVED_HEADERS: [&'static str; 7] = [
"accept",
"content-type",
"mcp-session-id",
"content-length",
"transfer-encoding",
"connection",
"host",
];
pub fn with_header(mut self, name: &str, value: impl AsRef<str>) -> Result<Self, McpError> {
let header = reqwest::header::HeaderName::from_bytes(name.as_bytes())
.map_err(|e| McpError::Transport(format!("invalid header name {name:?}: {e}")))?;
if Self::RESERVED_HEADERS.contains(&header.as_str()) {
return Err(McpError::Transport(format!(
"header {name:?} is set by the MCP transport or HTTP client and cannot be overridden"
)));
}
if header == reqwest::header::AUTHORIZATION && self.authorization.is_some() {
return Err(Self::two_authorizations());
}
let invalid = || McpError::Transport(format!("invalid value for header {name:?}"));
let mut value =
reqwest::header::HeaderValue::from_str(value.as_ref()).map_err(|_| invalid())?;
if value.to_str().is_err() {
return Err(invalid());
}
value.set_sensitive(true);
if is_credential_header(&header) {
if let Ok(text) = value.to_str() {
self.fixed_secrets.push(text.to_owned());
}
}
self.headers.insert(header, value);
Ok(self)
}
const MAX_ISSUED: usize = 256;
fn two_authorizations() -> McpError {
McpError::Transport(
"an Authorization header and an authorization source cannot both be set: \
the source supplies the header"
.into(),
)
}
pub fn with_authorization(
mut self,
source: Arc<dyn AuthorizationSource>,
) -> Result<Self, McpError> {
if self.headers.contains_key(reqwest::header::AUTHORIZATION) {
return Err(Self::two_authorizations());
}
self.authorization = Some(source);
Ok(self)
}
fn remember(&self, value: String) {
let mut issued = self.issued.lock().unwrap_or_else(|e| e.into_inner());
if issued.contains(&value) {
return;
}
if issued.len() >= Self::MAX_ISSUED {
issued.remove(0);
}
issued.push(value);
}
fn redactor(&self) -> UrlSecrets {
let issued = self.issued.lock().unwrap_or_else(|e| e.into_inner());
self.secrets.with_values(
self.fixed_secrets
.iter()
.chain(issued.iter())
.map(String::as_str),
)
}
fn authorization_value(
&self,
value: &str,
call: &str,
method: &str,
) -> Result<reqwest::header::HeaderValue, McpError> {
let invalid = || {
McpError::Transport(format!(
"the authorization source's {call} returned a value for '{method}' that is not \
a valid header value (visible ASCII); not sent"
))
};
let mut header = reqwest::header::HeaderValue::from_str(value).map_err(|_| invalid())?;
if header.to_str().is_err() {
return Err(invalid());
}
header.set_sensitive(true);
self.remember(value.to_owned());
Ok(header)
}
async fn send_authorized(
&self,
method: &str,
build: impl Fn() -> reqwest::RequestBuilder,
context: impl Fn() -> String,
) -> Result<Sent, McpError> {
let context = &context;
let send = |builder: reqwest::RequestBuilder| async move {
builder.send().await.map_err(|e| http_error(&context(), e))
};
let Some(source) = &self.authorization else {
let resp = send(build()).await?;
return Ok(Sent {
resp,
retried: false,
});
};
let with = |value: Option<reqwest::header::HeaderValue>| match value {
Some(value) => build().header(reqwest::header::AUTHORIZATION, value),
None => build(),
};
let source_failed = |when: &str, e: McpError| {
McpError::Transport(format!(
"the authorization source failed {when} '{method}': {e}"
))
};
let first = source
.authorization()
.await
.map_err(|e| source_failed("before", e))?;
let header = match &first {
Some(value) => Some(self.authorization_value(value, "authorization()", method)?),
None => None,
};
let resp = send(with(header)).await?;
if resp.status() != reqwest::StatusCode::UNAUTHORIZED {
return Ok(Sent {
resp,
retried: false,
});
}
let mut challenge = AuthChallenge::new(401, method);
challenge.www_authenticate = challenge_of(&resp);
challenge.refused = first.clone();
let Some(retry) = source
.challenged(&challenge)
.await
.map_err(|e| source_failed("after HTTP 401 on", e))?
else {
return Ok(Sent {
resp,
retried: false,
});
};
if Some(&retry) == first.as_ref() {
warn!(
"the authorization source returned the value the server just refused on \
'{method}' (HTTP 401); not retrying"
);
return Ok(Sent {
resp,
retried: false,
});
}
let header = self.authorization_value(&retry, "challenged()", method)?;
debug!("MCP server refused the credentials on '{method}' (HTTP 401); retrying once with the source's new value");
let resp = send(with(Some(header))).await?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
warn!(
"MCP server refused the authorization source's new value too on '{method}' \
(HTTP 401)"
);
}
Ok(Sent {
resp,
retried: true,
})
}
async fn status_error(&self, sent: Sent, what: &str) -> McpError {
let Sent { resp, retried } = sent;
let status = resp.status();
let challenge = challenge_of(&resp);
let again = if retried && status == reqwest::StatusCode::UNAUTHORIZED {
" (refused again after the authorization source supplied a new value)"
} else {
""
};
let message = match resp.text().await {
Ok(body) if body.trim().is_empty() => {
format!("HTTP {status} from server on {what}{again}")
}
Ok(body) => format!(
"HTTP {status} from server on {what}{again}: {}",
self.redactor().excerpt(body.trim())
),
Err(e) => format!(
"HTTP {status} from server on {what}{again} (body unreadable: {})",
e.without_url()
),
};
McpError::http(status.as_u16(), message).with_www_authenticate(challenge)
}
fn request(&self, method: reqwest::Method) -> reqwest::RequestBuilder {
self.client
.request(method, &self.base_url)
.headers(self.headers.clone())
}
fn response_for(payload: &str, request_id: u64) -> Option<JsonRpcResponse> {
let value: serde_json::Value = serde_json::from_str(payload).ok()?;
if value.get("method").is_some() {
return None;
}
if value.get("result").is_none() && value.get("error").is_none() {
return None;
}
let response: JsonRpcResponse = serde_json::from_value(value).ok()?;
if response.id.is_some_and(|id| id != request_id) {
return None;
}
Some(response)
}
fn event_payload(event: &str) -> Option<String> {
let data: Vec<&str> = event
.lines()
.filter_map(|line| {
line.strip_prefix("data:")
.map(|d| d.trim_start_matches(' '))
})
.collect();
if data.is_empty() {
return None;
}
let payload = data.join("\n");
let payload = payload.trim().to_string();
(!payload.is_empty()).then_some(payload)
}
fn note_skipped(
payload: &str,
method: &str,
skipped: &mut BTreeMap<String, usize>,
notifications: Option<&ServerNotifications>,
) {
let what = match serde_json::from_str::<serde_json::Value>(payload) {
Ok(value) => match value.get("method").and_then(|m| m.as_str()) {
Some(m) => {
note_notification(m, notifications);
m.to_string()
}
None => match value.get("id").and_then(|i| i.as_u64()) {
Some(id) => format!("response for id {id}"),
None => "unrecognized JSON-RPC frame".to_string(),
},
},
Err(e) => {
warn!(
"SSE frame on '{method}' is not valid JSON ({} bytes): {e}; \
a truncated frame may be the server's real answer",
payload.len()
);
format!("malformed non-JSON frame ({} bytes)", payload.len())
}
};
debug!("skipping SSE frame that is not the response to '{method}': {what}");
*skipped.entry(what).or_default() += 1;
}
fn describe_skipped(skipped: &BTreeMap<String, usize>) -> String {
if skipped.is_empty() {
return String::new();
}
let total: usize = skipped.values().sum();
let listed: Vec<String> = skipped
.iter()
.take(10)
.map(|(what, n)| {
if *n > 1 {
format!("{what} x{n}")
} else {
what.clone()
}
})
.collect();
let more = skipped.len().saturating_sub(listed.len());
let tail = if more > 0 {
format!(", and {more} other kind(s)")
} else {
String::new()
};
format!(" (skipped {total} frame(s): {}{tail})", listed.join(", "))
}
fn decode(bytes: &[u8], method: &str) -> Result<String, McpError> {
std::str::from_utf8(bytes).map(str::to_owned).map_err(|e| {
McpError::Transport(format!(
"invalid UTF-8 in the response body on '{method}' at byte {}: {e}",
e.valid_up_to()
))
})
}
async fn read_response(
resp: reqwest::Response,
request_id: u64,
method: &str,
status: reqwest::StatusCode,
notifications: Option<&ServerNotifications>,
secrets: &UrlSecrets,
) -> Result<JsonRpcResponse, McpError> {
if let Some(charset) = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.and_then(|ct| {
ct.split(';').skip(1).find_map(|p| {
p.trim()
.strip_prefix("charset=")
.map(|c| c.trim_matches('"').to_ascii_lowercase())
})
})
{
if !matches!(charset.as_str(), "utf-8" | "utf8" | "us-ascii" | "ascii") {
return Err(McpError::Transport(format!(
"unsupported charset '{charset}' on '{method}': MCP bodies are UTF-8 \
(both application/json and text/event-stream mandate it)"
)));
}
}
let mut buf: Vec<u8> = Vec::new();
let mut scanned = 0usize;
let mut searched = 0usize;
let mut skipped: BTreeMap<String, usize> = BTreeMap::new();
let mut pending_cr = false;
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| {
let e = e.without_url();
McpError::Transport(format!(
"Response read error on '{method}' after {} byte(s){}: {e}",
buf.len(),
Self::describe_skipped(&skipped)
))
})?;
for &b in chunk.iter() {
if b == b'\r' {
buf.push(b'\n');
pending_cr = true;
} else {
if pending_cr && b == b'\n' {
pending_cr = false;
continue;
}
pending_cr = false;
buf.push(b);
}
}
loop {
let from = scanned.max(searched);
let Some(pos) = buf[from..].windows(2).position(|w| w == b"\n\n") else {
searched = buf.len().saturating_sub(1);
break;
};
let end = from + pos;
let event = Self::decode(&buf[scanned..end], method)?;
scanned = end + 2;
searched = scanned;
let Some(payload) = Self::event_payload(&event) else {
continue;
};
if let Some(response) = Self::response_for(&payload, request_id) {
return Ok(response);
}
Self::note_skipped(&payload, method, &mut skipped, notifications);
}
}
let body = Self::decode(&buf, method)?;
let body = body.strip_prefix('\u{feff}').unwrap_or(&body).to_string();
if body.trim().is_empty() {
if status == reqwest::StatusCode::ACCEPTED || status == reqwest::StatusCode::NO_CONTENT
{
return Ok(JsonRpcResponse {
jsonrpc: "2.0".into(),
id: Some(request_id),
result: None,
error: None,
});
}
return Err(McpError::Transport(format!(
"HTTP {status} with an empty body on '{method}' (expected a JSON-RPC response; \
an empty 2xx usually means a proxy or gateway answered instead of the MCP server)"
)));
}
if let Some(response) = Self::response_for(&body, request_id) {
return Ok(response);
}
if scanned < buf.len() {
let tail = Self::decode(&buf[scanned..], method)?;
if let Some(payload) = Self::event_payload(&tail) {
if let Some(response) = Self::response_for(&payload, request_id) {
return Ok(response);
}
Self::note_skipped(&payload, method, &mut skipped, notifications);
}
}
Err(McpError::Transport(format!(
"HTTP {status}: no JSON-RPC response for '{method}' (id {request_id}) in the body{}: {}",
Self::describe_skipped(&skipped),
secrets.excerpt(&body)
)))
}
}
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
impl McpTransport for HttpTransport {
async fn send(&self, request: JsonRpcRequest) -> Result<JsonRpcResponse, McpError> {
let request_id = request.id;
let method = request.method.clone();
let session = self.session_id.lock().await.clone();
let build = || {
let builder = self
.request(reqwest::Method::POST)
.header("Accept", "application/json, text/event-stream")
.json(&request);
match &session {
Some(session) => builder.header("Mcp-Session-Id", session),
None => builder,
}
};
let sent = self
.send_authorized(&method, build, || {
format!("HTTP error reaching {}", self.origin)
})
.await?;
let status = sent.resp.status();
if !status.is_success() {
if let Some(expired) = self.expired_session(status, &method).await {
return Err(expired);
}
return Err(self.status_error(sent, &format!("'{method}'")).await);
}
let resp = sent.resp;
match resp.headers().get("mcp-session-id").map(|v| v.to_str()) {
Some(Ok(session)) => *self.session_id.lock().await = Some(session.to_owned()),
Some(Err(e)) => warn!("ignoring unreadable Mcp-Session-Id header: {e}"),
None => {}
}
let redactor = self.redactor();
Self::read_response(
resp,
request_id,
&method,
status,
self.notifications.as_ref(),
&redactor,
)
.await
.map(|mut response| {
if let Some(error) = response.error.as_mut() {
error.message = redactor.scrub(&error.message);
}
response
})
}
async fn notify(&self, notification: JsonRpcNotification) -> Result<(), McpError> {
let method = notification.method.clone();
let session = self.session_id.lock().await.clone();
let build = || {
let builder = self
.request(reqwest::Method::POST)
.header("Accept", "application/json, text/event-stream")
.json(¬ification);
match &session {
Some(session) => builder.header("Mcp-Session-Id", session),
None => builder,
}
};
let sent = self
.send_authorized(&method, build, || {
format!("HTTP error on '{method}' reaching {}", self.origin)
})
.await?;
let status = sent.resp.status();
if status.is_success() {
Ok(())
} else if let Some(expired) = self.expired_session(status, &method).await {
Err(expired)
} else {
Err(self
.status_error(sent, &format!("notification '{method}'"))
.await)
}
}
fn report_notifications(&mut self, to: ServerNotifications) -> bool {
self.notifications = Some(to);
true
}
async fn close(&self) -> Result<(), McpError> {
let session = self.session_id.lock().await.take();
if let Some(session) = session {
let build = || {
self.request(reqwest::Method::DELETE)
.header("Mcp-Session-Id", &session)
};
match self
.send_authorized("DELETE", build, || "DELETE".into())
.await
{
Ok(sent) if sent.resp.status().is_success() => {}
Ok(sent) if matches!(sent.resp.status().as_u16(), 401 | 403) => warn!(
"MCP session teardown refused with HTTP {} (credentials); \
the session may leak server-side",
sent.resp.status()
),
Ok(sent) => debug!(
"MCP session teardown rejected with HTTP {}; it is optional, continuing",
sent.resp.status()
),
Err(e) => warn!(
"MCP session teardown with {} failed: {e}; the session may leak server-side",
named_origin(&self.base_url),
),
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn credential_values_are_listed_whole_and_after_their_scheme() {
let secrets = UrlSecrets::default().with_values([
"Bearer abcdefgh123",
"abcdefgh456",
"Bearer short",
"Basic dXNlcjpwYXNz",
]);
assert_eq!(
secrets.0,
[
"Basic dXNlcjpwYXNz",
"Bearer abcdefgh123",
"Bearer short",
"dXNlcjpwYXNz",
"abcdefgh123",
"abcdefgh456",
]
);
assert_eq!(
secrets.scrub("got abcdefgh123 and Bearer short"),
"got [redacted] and [redacted]"
);
}
#[test]
fn credential_headers_are_told_from_others() {
let is = |name: &str| {
is_credential_header(&reqwest::header::HeaderName::from_bytes(name.as_bytes()).unwrap())
};
for name in [
"Authorization",
"proxy-authorization",
"Cookie",
"X-Api-Key",
"x-auth-token",
"client-secret",
"x-session",
] {
assert!(is(name), "{name}");
}
for name in ["user-agent", "accept-language", "x-request-id"] {
assert!(!is(name), "{name}");
}
}
#[test]
fn url_secrets_hide_keys_and_leave_a_plain_path_readable() {
let plain = UrlSecrets::of("https://host/mcp?v=1");
assert_eq!(
plain.excerpt("Cannot POST /mcp?v=1"),
"Cannot POST /mcp?v=1"
);
let keyed = UrlSecrets::of(
"https://u:pa%24%24word-long@host/zap/k3y-0123456789/mcp?api_key=s3cr3t%2Bvalue&v=1",
);
let text = keyed.excerpt(
"POST /zap/k3y-0123456789/mcp?api_key=s3cr3t%2Bvalue&v=1 / k3y-0123456789 / \
s3cr3t+value / pa$$word-long / v=1",
);
assert_eq!(
text,
"POST [redacted]?[redacted] / [redacted] / [redacted] / [redacted] / v=1"
);
assert_eq!(percent_decoded("a%2Fb%zz%"), "a/b%zz%");
}
#[cfg(all(feature = "native", unix))]
#[tokio::test]
async fn a_drop_with_the_child_locked_does_not_hang() {
let transport = StdioTransport::with_options(
"sleep",
&["30"],
None,
StdioOptions::new().own_process_group(true),
)
.await
.unwrap();
let child = transport.child.clone();
let held = child.try_lock().unwrap();
let started = std::time::Instant::now();
drop(transport);
assert!(started.elapsed() < std::time::Duration::from_secs(2));
drop(held);
}
#[cfg(all(feature = "native", unix))]
#[tokio::test]
async fn a_busy_child_repeats_the_provisional_answer() {
let transport =
StdioTransport::new("bash", &["-c", "echo bye >&2; sleep 3 & exit 5"], None)
.await
.unwrap();
let watch = transport.exit_watch().unwrap();
let mut first = None;
for _ in 0..200 {
first = watch.exited().filter(|why| why.contains("bye"));
if first.is_some() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
let first = first.expect("the exit was seen, with its stderr");
let busy = transport.child.lock().await;
assert_eq!(watch.exited().as_deref(), Some(first.as_str()));
assert_eq!(watch.exited().as_deref(), Some(first.as_str()));
drop(busy);
assert_eq!(first.matches("last stderr").count(), 1, "{first}");
}
#[cfg(all(feature = "native", unix))]
#[tokio::test]
async fn the_exit_watch_does_not_wait_on_a_busy_child() {
let transport = StdioTransport::new("bash", &["-c", "echo bye >&2; exit 5"], None)
.await
.unwrap();
let watch = transport.exit_watch().unwrap();
let busy = transport.child.lock().await;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert_eq!(watch.exited(), None, "the child's lock is held");
drop(busy);
let mut why = None;
for _ in 0..200 {
why = watch.exited();
if why.is_some() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
let why = why.expect("the exit was seen");
assert!(
why.contains("exit status: 5") && why.contains("bye"),
"{why}"
);
assert_eq!(watch.exited().as_deref(), Some(why.as_str()), "latched");
}
#[test]
fn caller_header_values_are_sensitive() {
let transport = HttpTransport::new("http://127.0.0.1:9/mcp")
.unwrap()
.with_header("x-api-key", "s3cret")
.unwrap();
assert!(transport.headers["x-api-key"].is_sensitive());
assert!(!format!("{:?}", transport.headers).contains("s3cret"));
}
#[cfg(feature = "native")]
#[tokio::test]
async fn test_stdio_transport_with_cat() {
let transport = StdioTransport::new("cat", &[], None).await.unwrap();
let request = JsonRpcRequest::new("test/echo", Some(serde_json::json!({"hello": "world"})));
let request_id = request.id;
let mut line = serde_json::to_string(&request).unwrap();
line.push('\n');
{
let mut writer = transport.stdin.lock().await;
writer.stdin.write_all(line.as_bytes()).await.unwrap();
writer.stdin.flush().await.unwrap();
}
let mut response_line = String::new();
{
let mut stdout = transport.stdout.lock().await;
stdout.read_line(&mut response_line).await.unwrap();
}
let echoed: JsonRpcRequest = serde_json::from_str(response_line.trim()).unwrap();
assert_eq!(echoed.id, request_id);
assert_eq!(echoed.method, "test/echo");
transport.close().await.unwrap();
}
#[test]
fn test_http_transport_creation() {
let transport = HttpTransport::new("http://localhost:8080/mcp").unwrap();
assert_eq!(transport.base_url, "http://localhost:8080/mcp");
let transport = HttpTransport::new("http://localhost:8080/mcp/").unwrap();
assert_eq!(transport.base_url, "http://localhost:8080/mcp");
}
}