use serde_json::{Value, json};
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio::time::timeout;
use tracing::{debug, info, warn};
use turul_rpc::{JsonRpcNotification, JsonRpcRequest, RequestId, RequestParams};
use crate::config::ClientConfig;
use crate::error::{McpClientError, McpClientResult, SessionError};
use crate::session::{SessionManager, SessionState};
use crate::streaming::StreamHandler;
use crate::transport::BoxedTransport;
use turul_mcp_protocol_2025_11_25::completion::{
CompleteArgument, CompleteResult, CompletionContext, CompletionReference,
};
use turul_mcp_protocol_2025_11_25::meta::Cursor;
use turul_mcp_protocol_2025_11_25::resources::{ListResourceTemplatesResult, ResourceTemplate};
use turul_mcp_protocol_2025_11_25::tasks::{
CancelTaskResult, CreateTaskResult, GetTaskResult, ListTasksResult, Task,
};
use turul_mcp_protocol_2025_11_25::{
CallToolResult, GetPromptResult, InitializeResult, ListPromptsResult, ListResourcesResult,
ListToolsResult, Prompt, ReadResourceResult, Resource, Tool,
};
pub type NotificationCallback = Arc<dyn Fn(&str, Option<&Value>) + Send + Sync>;
type ParamBindingMap = std::collections::HashMap<String, Vec<(String, String, String)>>;
pub struct McpClient {
transport: Arc<BoxedTransport>,
session: Arc<SessionManager>,
config: ClientConfig,
stream_handler: Arc<tokio::sync::Mutex<StreamHandler>>,
request_counter: Arc<std::sync::atomic::AtomicU64>,
response_consumer_handle: Arc<parking_lot::Mutex<Option<tokio::task::JoinHandle<()>>>>,
cached_tools: Arc<RwLock<Option<Vec<Tool>>>>,
cached_param_bindings: Arc<RwLock<ParamBindingMap>>,
cached_tool_schemas: Arc<RwLock<std::collections::HashMap<String, Value>>>,
cached_resources: Arc<RwLock<Option<Vec<Resource>>>>,
cached_prompts: Arc<RwLock<Option<Vec<Prompt>>>>,
discovered: Arc<RwLock<Option<DiscoveredServer>>>,
notification_callback: Option<NotificationCallback>,
protocol_version: Arc<RwLock<Option<crate::version::McpVersion>>>,
}
#[derive(Debug, Clone)]
pub struct DiscoveredServer {
pub server_info: Option<Value>,
pub capabilities: Option<Value>,
pub instructions: Option<String>,
pub supported_versions: Vec<String>,
}
impl DiscoveredServer {
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
fn from_result(result: &Value) -> Self {
Self {
server_info: result
.get("_meta")
.and_then(|m| m.get(turul_mcp_protocol_2026_07_28::meta::META_KEY_SERVER_INFO))
.cloned(),
capabilities: result.get("capabilities").cloned(),
instructions: result
.get("instructions")
.and_then(|v| v.as_str())
.map(String::from),
supported_versions: result
.get("supportedVersions")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
}
}
}
fn extract_supported_versions(error: &Value) -> Option<Vec<String>> {
error
.pointer("/data/supported")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
}
impl Drop for McpClient {
fn drop(&mut self) {
if let Some(handle) = self.response_consumer_handle.lock().take() {
handle.abort();
}
let session_id = self.session.clone();
let transport = self.transport.clone();
tokio::spawn(async move {
if let Some(session_id_str) = session_id.session_id_optional().await {
info!(
session_id = session_id_str,
"McpClient dropped - attempting session cleanup via DELETE request"
);
if let Err(e) = transport.send_delete(&session_id_str).await {
warn!(
session_id = session_id_str,
error = %e,
"Failed to send DELETE request during Drop cleanup"
);
} else {
info!(
session_id = session_id_str,
"Successfully sent DELETE request during Drop cleanup"
);
}
} else {
debug!("No session ID available, skipping DELETE request during Drop");
}
session_id
.terminate(Some("Client dropped".to_string()))
.await;
});
}
}
impl McpClient {
pub fn new(transport: BoxedTransport, config: ClientConfig) -> Self {
Self::new_with_callback(transport, config, None)
}
fn new_with_callback(
transport: BoxedTransport,
config: ClientConfig,
notification_callback: Option<NotificationCallback>,
) -> Self {
let session = Arc::new(SessionManager::new(config.clone()));
Self {
transport: Arc::new(transport),
session,
config,
stream_handler: Arc::new(tokio::sync::Mutex::new(StreamHandler::new())),
request_counter: Arc::new(std::sync::atomic::AtomicU64::new(0)),
response_consumer_handle: Arc::new(parking_lot::Mutex::new(None)),
cached_tools: Arc::new(RwLock::new(None)),
cached_param_bindings: Arc::new(RwLock::new(std::collections::HashMap::new())),
cached_tool_schemas: Arc::new(RwLock::new(std::collections::HashMap::new())),
cached_resources: Arc::new(RwLock::new(None)),
cached_prompts: Arc::new(RwLock::new(None)),
discovered: Arc::new(RwLock::new(None)),
notification_callback,
protocol_version: Arc::new(RwLock::new(None)),
}
}
pub async fn connect(&self) -> McpClientResult<()> {
info!("Connecting to MCP server");
if let Some(handle) = self.response_consumer_handle.lock().take() {
handle.abort();
}
self.transport.connect().await?;
self.negotiate_protocol().await?;
self.start_server_event_listener().await?;
info!("Successfully connected to MCP server");
Ok(())
}
async fn start_server_event_listener(&self) -> McpClientResult<()> {
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
debug!("Skipping the GET SSE listener: 2026-07-28 has no GET stream");
return Ok(());
}
{
if self.transport.capabilities().server_events {
let receiver = self.transport.start_event_listener().await?;
let (response_tx, mut response_rx) =
tokio::sync::mpsc::unbounded_channel::<serde_json::Value>();
let mut stream_handler = self.stream_handler.lock().await;
stream_handler.set_receiver(receiver);
stream_handler.set_response_sender(response_tx);
{
let cached_tools = Arc::clone(&self.cached_tools);
let cached_resources = Arc::clone(&self.cached_resources);
let cached_prompts = Arc::clone(&self.cached_prompts);
let user_callback = self.notification_callback.clone();
stream_handler.on_notification(move |notification| {
let method = notification
.get("method")
.and_then(|m| m.as_str())
.unwrap_or("");
let params = notification.get("params");
match method {
"notifications/tools/list_changed" => {
info!("Server sent notifications/tools/list_changed — invalidating tool cache");
if let Ok(mut cache) = cached_tools.try_write() {
*cache = None;
} else {
warn!("Could not acquire tool cache write lock for invalidation");
}
}
"notifications/resources/list_changed" => {
info!("Server sent notifications/resources/list_changed — invalidating resource cache");
if let Ok(mut cache) = cached_resources.try_write() {
*cache = None;
} else {
warn!("Could not acquire resource cache write lock for invalidation");
}
}
"notifications/prompts/list_changed" => {
info!("Server sent notifications/prompts/list_changed — invalidating prompt cache");
if let Ok(mut cache) = cached_prompts.try_write() {
*cache = None;
} else {
warn!("Could not acquire prompt cache write lock for invalidation");
}
}
_ => {
debug!(method = method, "Received server notification");
}
}
if let Some(ref cb) = user_callback {
cb(method, params);
}
});
}
stream_handler.start().await?;
let transport_clone = Arc::clone(&self.transport);
let consumer_handle = tokio::spawn(async move {
while let Some(response) = response_rx.recv().await {
if let Err(e) = transport_clone.send_notification(response).await {
warn!("Failed to send response back to server: {}", e);
}
}
debug!("Response consumer task stopped");
});
*self.response_consumer_handle.lock() = Some(consumer_handle);
}
}
Ok(())
}
pub async fn negotiated_version(&self) -> Option<crate::version::McpVersion> {
*self.protocol_version.read().await
}
async fn lock_version(&self, v: crate::version::McpVersion) -> McpClientResult<()> {
*self.protocol_version.write().await = Some(v);
self.transport.set_protocol_version(v.as_str());
if v == crate::version::McpVersion::V2026_07_28 {
self.session
.set_state(crate::session::SessionState::Active)
.await;
}
info!(version = %v, "Negotiated MCP wire version");
Ok(())
}
#[cfg(feature = "client-bilingual")]
async fn negotiate_protocol(&self) -> McpClientResult<()> {
use crate::version::{McpVersion, ProbeDecision, classify_probe};
if let Some(hint) = self.config.mcp_protocol_version {
match hint {
McpVersion::V2025_11_25 => self.initialize_session().await?,
McpVersion::V2026_07_28 => {}
}
return self.lock_version(hint).await;
}
let probe = self.probe_discover().await?;
match classify_probe(probe, self.config.allow_legacy_gateway_fallback) {
ProbeDecision::Use2026 => self.lock_version(McpVersion::V2026_07_28).await,
ProbeDecision::FallbackTo2025 => {
self.transport
.set_protocol_version(McpVersion::V2025_11_25.as_str());
self.initialize_session().await?;
self.lock_version(McpVersion::V2025_11_25).await
}
ProbeDecision::Abort(reason) => {
Err(crate::error::ProtocolError::NegotiationFailed(reason).into())
}
}
}
#[cfg(feature = "client-2025-11-25-only")]
async fn negotiate_protocol(&self) -> McpClientResult<()> {
self.initialize_session().await?;
self.lock_version(crate::version::McpVersion::V2025_11_25)
.await
}
#[cfg(feature = "client-2026-07-28-only")]
async fn negotiate_protocol(&self) -> McpClientResult<()> {
match self.probe_discover().await? {
crate::version::DiscoverProbe::Discovered => {
self.lock_version(crate::version::McpVersion::V2026_07_28).await
}
_ => Err(crate::error::ProtocolError::UnsupportedVersion(
"client-2026-07-28-only: server did not answer server/discover — not a 2026-07-28 server"
.to_string(),
)
.into()),
}
}
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
async fn probe_discover(&self) -> McpClientResult<crate::version::DiscoverProbe> {
use crate::error::TransportError;
use crate::version::DiscoverProbe;
use turul_mcp_protocol_2026_07_28 as p2026;
let meta = p2026::meta::RequestMetaObject::new(
p2026::MCP_VERSION,
p2026::initialize::Implementation::new(
self.config.client_info.name.clone(),
self.config.client_info.version.clone(),
),
p2026::initialize::ClientCapabilities::default(),
);
let discover = p2026::discover::DiscoverRequest::new(meta);
let params = serde_json::to_value(&discover.params).map_err(|e| {
McpClientError::generic(format!("Failed to serialize server/discover params: {}", e))
})?;
let envelope = self.build_request("server/discover", params);
self.transport.set_protocol_version(p2026::MCP_VERSION);
match timeout(
self.config.timeouts.initialization,
self.send_request_with_headers_internal(envelope),
)
.await
{
Err(_) => Err(McpClientError::Timeout),
Ok(Ok(resp)) => {
if let Some(err) = resp.body.get("error") {
let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(0);
Ok(DiscoverProbe::JsonRpcError(
code,
extract_supported_versions(err),
))
} else if let Some(result) = resp.body.get("result") {
*self.discovered.write().await = Some(DiscoveredServer::from_result(result));
Ok(DiscoverProbe::Discovered)
} else {
Err(crate::error::ProtocolError::InvalidResponse(
"server/discover response carried neither result nor error".to_string(),
)
.into())
}
}
Ok(Err(McpClientError::Transport(TransportError::HttpStatus { status, message }))) => {
if let Ok(body) = serde_json::from_str::<serde_json::Value>(&message)
&& let Some(code) = body.pointer("/error/code").and_then(|c| c.as_i64())
{
let supported = body.get("error").and_then(extract_supported_versions);
return Ok(DiscoverProbe::JsonRpcError(code, supported));
}
Ok(DiscoverProbe::HttpStatus(status))
}
Ok(Err(other)) => Err(other),
}
}
pub async fn disconnect(&self) -> McpClientResult<()> {
info!("Disconnecting from MCP server");
if let Some(handle) = self.response_consumer_handle.lock().take() {
handle.abort();
}
if let Some(session_id) = self.session.session_id_optional().await {
if let Err(e) = self.transport.send_delete(&session_id).await {
warn!(
session_id = session_id,
error = %e,
"Failed to send DELETE request during disconnect - continuing with cleanup"
);
}
} else {
debug!("No session ID available, skipping DELETE request during disconnect");
}
self.session
.terminate(Some("Client disconnect".to_string()))
.await;
self.transport.disconnect().await?;
info!("Disconnected from MCP server");
Ok(())
}
pub async fn set_bearer(&self, token: Option<&str>) {
let value = token.map(|t| format!("Bearer {}", t));
self.transport.update_auth_header(value).await;
}
pub async fn is_ready(&self) -> bool {
let transport_connected = self.transport.is_connected();
let session_ready = self.session.is_ready().await;
transport_connected && session_ready
}
pub async fn connection_status(&self) -> ConnectionStatus {
let transport_info = self.transport.connection_info();
let session_stats = self.session.statistics().await;
ConnectionStatus {
transport_connected: transport_info.connected,
session_state: session_stats.state,
transport_type: transport_info.transport_type,
endpoint: transport_info.endpoint,
session_id: session_stats.session_id,
protocol_version: session_stats.protocol_version,
}
}
async fn initialize_session(&self) -> McpClientResult<()> {
info!("Initializing MCP session");
self.session.mark_initializing().await?;
let init_request = self.session.create_initialize_request().await;
let request_json = serde_json::to_value(&init_request).map_err(|e| {
McpClientError::generic(format!("Failed to serialize initialize request: {}", e))
})?;
let json_rpc_request = self.build_request("initialize", request_json);
let response = timeout(
self.config.timeouts.initialization,
self.send_request_with_headers_internal(json_rpc_request),
)
.await
.map_err(|_| McpClientError::Timeout)?;
let transport_response = response?;
let session_id = transport_response
.headers
.iter()
.find(|(key, _)| key.to_lowercase() == "mcp-session-id")
.map(|(_, value)| value.clone());
if let Some(session_id) = session_id {
info!("Server provided session ID: {}", session_id);
self.session.set_session_id(session_id.clone()).await?;
self.transport.set_session_id(session_id);
} else {
debug!("Server did not provide Mcp-Session-Id — stateless session (spec-valid)");
}
let init_response: InitializeResult = serde_json::from_value(
transport_response
.body
.get("result")
.cloned()
.unwrap_or(Value::Null),
)
.map_err(|e| {
McpClientError::generic(format!("Failed to parse initialize response: {}", e))
})?;
SessionManager::validate_protocol_version(&init_response.protocol_version)?;
self.session
.validate_server_capabilities(&init_response.capabilities)
.await?;
self.session
.initialize(
init_request.capabilities,
init_response.capabilities,
init_response.protocol_version,
)
.await?;
let initialized_notification =
Self::build_notification("notifications/initialized", json!({}));
self.send_notification_internal(initialized_notification)
.await?;
info!("MCP session initialized successfully");
Ok(())
}
fn next_request_id(&self) -> String {
let counter = self
.request_counter
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
format!("req_{}", counter)
}
fn build_request(&self, method: &str, params: Value) -> Value {
let id = RequestId::String(self.next_request_id());
let request = JsonRpcRequest::new(id, method.to_string(), value_to_request_params(params));
serde_json::to_value(request).expect("JsonRpcRequest serialization is infallible")
}
fn build_notification(method: &str, params: Value) -> Value {
let notification =
JsonRpcNotification::new(method.to_string(), value_to_request_params(params));
serde_json::to_value(notification).expect("JsonRpcNotification serialization is infallible")
}
async fn send_request_internal(&self, request: Value) -> McpClientResult<Value> {
let mut last_error = None;
for attempt in 0..self.config.retry.max_attempts {
if attempt > 0 {
let delay = self.config.retry.delay_for_attempt(attempt);
debug!(
attempt = attempt,
delay_ms = delay.as_millis(),
"Retrying request"
);
tokio::time::sleep(delay).await;
}
match self.send_request_raw(request.clone()).await {
Ok(response) => {
self.session.update_activity().await;
return Ok(response);
}
Err(e) => {
warn!(attempt = attempt, error = %e, "Request failed");
if e.is_session_expired()
&& self.negotiated_version().await
!= Some(crate::version::McpVersion::V2026_07_28)
{
warn!("Session expired (HTTP 404) — attempting re-initialization");
self.session.reset().await;
self.transport.clear_session_id();
if let Err(reinit_err) = self.initialize_session().await {
warn!(error = %reinit_err, "Re-initialization failed");
return Err(e);
}
continue;
}
if e.is_session_not_initialized() {
warn!(
"Session not initialized (code -32031) — \
disconnecting and reconnecting"
);
if let Err(dc_err) = self.disconnect().await {
warn!(error = %dc_err, "Disconnect during session retry failed");
}
self.session.reset().await;
if let Err(reconnect_err) = self.connect().await {
warn!(error = %reconnect_err, "Reconnect after -32031 failed");
return Err(e);
}
return match self.send_request_raw(request).await {
Ok(response) => {
self.session.update_activity().await;
Ok(response)
}
Err(retry_err) => {
warn!(error = %retry_err, "Retry after reconnect also failed");
Err(retry_err)
}
};
}
if !e.is_retryable() || !self.config.retry.should_retry(attempt + 1) {
return Err(e);
}
last_error = Some(e);
}
}
}
Err(last_error.unwrap_or_else(|| McpClientError::generic("All retry attempts failed")))
}
async fn send_request_with_headers_internal(
&self,
request: Value,
) -> McpClientResult<crate::transport::TransportResponse> {
let mut last_error = None;
for attempt in 0..self.config.retry.max_attempts {
if attempt > 0 {
let delay = self.config.retry.delay_for_attempt(attempt);
debug!(
attempt = attempt,
delay_ms = delay.as_millis(),
"Retrying request with headers"
);
tokio::time::sleep(delay).await;
}
match self.send_request_with_headers_raw(request.clone()).await {
Ok(response) => return Ok(response),
Err(e) => {
warn!(
attempt = attempt,
max_attempts = self.config.retry.max_attempts,
error = %e,
"Request with headers failed"
);
if !e.is_retryable() {
return Err(e);
}
last_error = Some(e);
}
}
}
Err(last_error.unwrap_or_else(|| McpClientError::generic("All retry attempts failed")))
}
async fn send_request_with_headers_raw(
&self,
request: Value,
) -> McpClientResult<crate::transport::TransportResponse> {
timeout(
self.config.timeouts.request,
self.transport.send_request_with_headers(request),
)
.await
.map_err(|_| McpClientError::Timeout)?
}
async fn send_request_raw(&self, request: Value) -> McpClientResult<Value> {
if !self.session.is_ready().await {
return Err(SessionError::NotInitialized.into());
}
let response = timeout(
self.config.timeouts.request,
self.transport.send_request(request),
)
.await
.map_err(|_| McpClientError::Timeout)??;
if let Some(error) = response.get("error") {
let code = error.get("code").and_then(|c| c.as_i64()).unwrap_or(-1) as i32;
let message = error
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error");
let data = error.get("data").cloned();
return Err(McpClientError::server_error(code, message, data));
}
Ok(response)
}
async fn send_notification_internal(&self, notification: Value) -> McpClientResult<()> {
self.transport.send_notification(notification).await?;
self.session.update_activity().await;
Ok(())
}
pub async fn list_tools(&self) -> McpClientResult<Vec<Tool>> {
{
let cache = self.cached_tools.read().await;
if let Some(ref tools) = *cache {
debug!(count = tools.len(), "Returning cached tools");
return Ok(tools.clone());
}
}
let tools = self.fetch_tools().await?;
{
let mut cache = self.cached_tools.write().await;
*cache = Some(tools.clone());
}
Ok(tools)
}
pub async fn tool_input_schema(&self, tool_name: &str) -> Option<Value> {
self.cached_tool_schemas
.read()
.await
.get(tool_name)
.cloned()
}
pub async fn refresh_tools(&self) -> McpClientResult<Vec<Tool>> {
let tools = self.fetch_tools().await?;
{
let mut cache = self.cached_tools.write().await;
*cache = Some(tools.clone());
}
Ok(tools)
}
async fn fetch_tools(&self) -> McpClientResult<Vec<Tool>> {
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
return self.fetch_tools_2026_07_28().await;
}
self.fetch_tools_2025_11_25().await
}
async fn fetch_tools_2025_11_25(&self) -> McpClientResult<Vec<Tool>> {
debug!("Fetching tools from server (2025-11-25)");
let request = self.build_request("tools/list", json!({}));
let response = self.send_request_internal(request).await?;
let tools_response: ListToolsResult =
serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?;
debug!(count = tools_response.tools.len(), "Retrieved tools");
Ok(tools_response.tools)
}
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
async fn fetch_tools_2026_07_28(&self) -> McpClientResult<Vec<Tool>> {
debug!("Fetching tools from server (2026-07-28)");
let meta = crate::protocol::v2026_07_28::request_meta(
&self.config.client_info.name,
&self.config.client_info.version,
&self.config.declared_capabilities,
);
let request = self.build_request(
"tools/list",
crate::protocol::v2026_07_28::params_with_meta(&meta, json!({})),
);
let response = self.send_request_internal(request).await?;
let raw_result = response.get("result").cloned().unwrap_or(Value::Null);
let tools = crate::protocol::v2026_07_28::parse_list_tools(&raw_result)?;
{
let bindings = crate::protocol::v2026_07_28::collect_param_bindings(&raw_result);
*self.cached_param_bindings.write().await = bindings;
let schemas = crate::protocol::v2026_07_28::collect_input_schemas(&raw_result);
*self.cached_tool_schemas.write().await = schemas;
}
debug!(count = tools.len(), "Retrieved tools");
Ok(tools)
}
pub async fn list_tools_paginated(
&self,
cursor: Option<Cursor>,
) -> McpClientResult<ListToolsResult> {
debug!("Listing tools with pagination");
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
let extra = match cursor {
Some(ref c) => json!({ "cursor": c.as_str() }),
None => json!({}),
};
return self
.send_2026_07_28(
"tools/list",
extra,
crate::protocol::v2026_07_28::parse_list_tools_result,
)
.await;
}
let request_params = if let Some(cursor) = cursor {
json!({ "cursor": cursor.as_str() })
} else {
json!({})
};
let request = self.build_request("tools/list", request_params);
let response = self.send_request_internal(request).await?;
let tools_response: ListToolsResult =
serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?;
debug!(
count = tools_response.tools.len(),
has_cursor = tools_response.next_cursor.is_some(),
"Retrieved tools with pagination"
);
Ok(tools_response)
}
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
async fn send_2026_07_28<T>(
&self,
method: &str,
extra: Value,
parse: impl Fn(&Value) -> McpClientResult<T>,
) -> McpClientResult<T> {
let meta = crate::protocol::v2026_07_28::request_meta(
&self.config.client_info.name,
&self.config.client_info.version,
&self.config.declared_capabilities,
);
let request = self.build_request(
method,
crate::protocol::v2026_07_28::params_with_meta(&meta, extra),
);
let response = self.send_request_internal(request).await?;
parse(&response.get("result").cloned().unwrap_or(Value::Null))
}
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
async fn mcp_param_headers_for(
&self,
tool_name: &str,
arguments: &Value,
) -> Vec<(String, String)> {
let cache = self.cached_param_bindings.read().await;
match cache.get(tool_name) {
Some(bindings) => {
crate::protocol::v2026_07_28::encode_param_headers(bindings, arguments)
}
None => Vec::new(),
}
}
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
async fn send_2026_07_28_with_extra_headers<T>(
&self,
method: &str,
extra: Value,
extra_headers: &[(String, String)],
parse: impl Fn(&Value) -> McpClientResult<T>,
) -> McpClientResult<T> {
let meta = crate::protocol::v2026_07_28::request_meta(
&self.config.client_info.name,
&self.config.client_info.version,
&self.config.declared_capabilities,
);
let request = self.build_request(
method,
crate::protocol::v2026_07_28::params_with_meta(&meta, extra),
);
if !self.session.is_ready().await {
return Err(crate::error::SessionError::NotInitialized.into());
}
let response = tokio::time::timeout(
self.config.timeouts.request,
self.transport
.send_request_with_extra_headers(request, extra_headers),
)
.await
.map_err(|_| McpClientError::Timeout)??;
if let Some(error) = response.get("error") {
let code = error.get("code").and_then(|c| c.as_i64()).unwrap_or(-1) as i32;
let message = error
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error");
let data = error.get("data").cloned();
return Err(McpClientError::server_error(code, message, data));
}
parse(&response.get("result").cloned().unwrap_or(Value::Null))
}
async fn reject_if_2026_07_28(&self, _method: &str) -> McpClientResult<()> {
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
return Err(crate::error::ProtocolError::MethodNotFound(format!(
"`{_method}` is not part of MCP 2026-07-28 (removed from core); this connection negotiated 2026"
))
.into());
}
Ok(())
}
pub async fn call_tool(&self, name: &str, arguments: Value) -> McpClientResult<CallToolResult> {
debug!(tool = name, "Calling tool");
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
let extra_headers = self.mcp_param_headers_for(name, &arguments).await;
let first = self
.send_2026_07_28_with_extra_headers(
"tools/call",
json!({ "name": name, "arguments": arguments.clone() }),
&extra_headers,
crate::protocol::v2026_07_28::parse_call_tool,
)
.await;
let is_header_mismatch = matches!(
&first,
Err(McpClientError::ServerError { code, .. })
if *code == turul_mcp_protocol_2026_07_28::headers::ERROR_CODE_HEADER_MISMATCH as i32
);
if is_header_mismatch {
debug!(
tool = name,
"Mcp-Param mismatch — refreshing tools/list and retrying once"
);
let _ = self.refresh_tools().await?;
let extra_headers = self.mcp_param_headers_for(name, &arguments).await;
return self
.send_2026_07_28_with_extra_headers(
"tools/call",
json!({ "name": name, "arguments": arguments.clone() }),
&extra_headers,
crate::protocol::v2026_07_28::parse_call_tool,
)
.await;
}
return first;
}
let request = self.build_request(
"tools/call",
json!({
"name": name,
"arguments": arguments
}),
);
let response = self.send_request_internal(request).await?;
let call_response: CallToolResult =
serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?;
debug!(
tool = name,
is_error = call_response.is_error,
"Tool call completed"
);
Ok(call_response)
}
pub async fn subscriptions_listen(&self, filter: Value) -> McpClientResult<SubscriptionStream> {
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
let meta = crate::protocol::v2026_07_28::request_meta(
&self.config.client_info.name,
&self.config.client_info.version,
&self.config.declared_capabilities,
);
let request = self.build_request(
"subscriptions/listen",
crate::protocol::v2026_07_28::params_with_meta(
&meta,
json!({ "notifications": filter }),
),
);
let request_id = request
.get("id")
.and_then(|id| id.as_str())
.map(String::from);
let mut receiver = self.transport.send_request_streaming(request).await?;
let ack = tokio::time::timeout(self.config.timeouts.request, receiver.recv())
.await
.map_err(|_| McpClientError::Timeout)?
.ok_or_else(|| {
crate::error::ProtocolError::InvalidResponse(
"subscriptions/listen stream closed before the acknowledgement".to_string(),
)
})?;
if ack.get("method").and_then(|m| m.as_str())
!= Some("notifications/subscriptions/acknowledged")
{
return Err(crate::error::ProtocolError::InvalidResponse(format!(
"first listen-stream message must be the acknowledgement, got: {ack}"
))
.into());
}
let honored = ack
.pointer("/params/notifications")
.cloned()
.unwrap_or(Value::Null);
let subscription_id = ack
.pointer("/params/_meta/io.modelcontextprotocol~1subscriptionId")
.or_else(|| {
ack.get("params")
.and_then(|p| p.get("_meta"))
.and_then(|m| m.get("io.modelcontextprotocol/subscriptionId"))
})
.and_then(|v| v.as_str())
.map(String::from);
return Ok(SubscriptionStream {
honored,
subscription_id,
request_id,
receiver,
});
}
let _ = filter;
Err(crate::error::ProtocolError::MethodNotFound(
"subscriptions/listen requires a 2026-07-28 connection".to_string(),
)
.into())
}
pub async fn discovered_server(&self) -> Option<DiscoveredServer> {
self.discovered.read().await.clone()
}
pub async fn server_capabilities(&self) -> Option<Value> {
self.discovered
.read()
.await
.as_ref()
.and_then(|d| d.capabilities.clone())
}
pub async fn server_instructions(&self) -> Option<String> {
self.discovered
.read()
.await
.as_ref()
.and_then(|d| d.instructions.clone())
}
pub async fn call_tool_with_progress(
&self,
name: &str,
arguments: Value,
progress_token: Value,
mut on_progress: impl FnMut(Value) + Send,
) -> McpClientResult<CallToolResult> {
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
let meta = crate::protocol::v2026_07_28::request_meta(
&self.config.client_info.name,
&self.config.client_info.version,
&self.config.declared_capabilities,
);
let mut params = crate::protocol::v2026_07_28::params_with_meta(
&meta,
json!({ "name": name, "arguments": arguments }),
);
if let Some(m) = params.get_mut("_meta").and_then(|m| m.as_object_mut()) {
m.insert("progressToken".to_string(), progress_token);
}
let request = self.build_request("tools/call", params);
let mut receiver = self.transport.send_request_streaming(request).await?;
let deadline = tokio::time::Instant::now() + self.config.timeouts.request;
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
let frame = tokio::time::timeout(remaining, receiver.recv())
.await
.map_err(|_| McpClientError::Timeout)?
.ok_or_else(|| {
crate::error::ProtocolError::InvalidResponse(
"request stream closed before the final response".to_string(),
)
})?;
if frame.get("method").and_then(|m| m.as_str()) == Some("notifications/progress") {
on_progress(frame.get("params").cloned().unwrap_or(Value::Null));
continue;
}
if let Some(err) = frame.get("error") {
return Err(McpClientError::server_error(
err.get("code").and_then(|c| c.as_i64()).unwrap_or(0) as i32,
err.get("message")
.and_then(|m| m.as_str())
.unwrap_or("server error"),
err.get("data").cloned(),
));
}
if frame.get("result").is_some() {
return crate::protocol::v2026_07_28::parse_call_tool(
frame.get("result").unwrap_or(&Value::Null),
);
}
}
}
let _ = (name, arguments, progress_token, &mut on_progress);
Err(crate::error::ProtocolError::MethodNotFound(
"per-request progress requires a 2026-07-28 connection".to_string(),
)
.into())
}
pub async fn call_tool_with_input_responses(
&self,
name: &str,
arguments: Value,
input_responses: Value,
request_state: Option<String>,
) -> McpClientResult<CallToolResult> {
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
let mut extra = json!({
"name": name,
"arguments": arguments,
"inputResponses": input_responses,
});
if let (Some(obj), Some(state)) = (extra.as_object_mut(), request_state) {
obj.insert("requestState".to_string(), Value::String(state));
}
return self
.send_2026_07_28(
"tools/call",
extra,
crate::protocol::v2026_07_28::parse_call_tool,
)
.await;
}
let _ = (name, arguments, input_responses, request_state);
Err(crate::error::ProtocolError::MethodNotFound(
"MRTR input responses require a 2026-07-28 connection".to_string(),
)
.into())
}
pub async fn read_resource_with_input_responses(
&self,
uri: &str,
input_responses: Value,
request_state: Option<String>,
) -> McpClientResult<Vec<turul_mcp_protocol_2025_11_25::ResourceContent>> {
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
let mut extra = json!({
"uri": uri,
"inputResponses": input_responses,
});
if let (Some(obj), Some(state)) = (extra.as_object_mut(), request_state) {
obj.insert("requestState".to_string(), Value::String(state));
}
let r = self
.send_2026_07_28(
"resources/read",
extra,
crate::protocol::v2026_07_28::parse_read_resource,
)
.await?;
return Ok(r.contents);
}
let _ = (uri, input_responses, request_state);
Err(crate::error::ProtocolError::MethodNotFound(
"MRTR input responses require a 2026-07-28 connection".to_string(),
)
.into())
}
pub async fn get_prompt_with_input_responses(
&self,
name: &str,
arguments: Option<Value>,
input_responses: Value,
request_state: Option<String>,
) -> McpClientResult<GetPromptResult> {
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
let mut extra = json!({
"name": name,
"inputResponses": input_responses,
});
if let (Some(obj), Some(args)) = (extra.as_object_mut(), arguments.clone()) {
obj.insert("arguments".to_string(), args);
}
if let (Some(obj), Some(state)) = (extra.as_object_mut(), request_state) {
obj.insert("requestState".to_string(), Value::String(state));
}
return self
.send_2026_07_28(
"prompts/get",
extra,
crate::protocol::v2026_07_28::parse_get_prompt,
)
.await;
}
let _ = (name, arguments, input_responses, request_state);
Err(crate::error::ProtocolError::MethodNotFound(
"MRTR input responses require a 2026-07-28 connection".to_string(),
)
.into())
}
#[cfg(feature = "ext-tasks")]
pub async fn call_tool_or_task(
&self,
name: &str,
arguments: Value,
) -> McpClientResult<ToolCallOutcome> {
if self.negotiated_version().await != Some(crate::version::McpVersion::V2026_07_28) {
return Err(crate::error::ProtocolError::MethodNotFound(
"the Tasks extension requires a 2026-07-28 connection".to_string(),
)
.into());
}
let extra = json!({ "name": name, "arguments": arguments });
self.send_2026_07_28_with_extra_headers("tools/call", extra, &[], |result| {
if result.get("resultType").and_then(|v| v.as_str())
== Some(turul_mcp_ext_tasks::RESULT_TYPE_TASK)
{
let task: turul_mcp_ext_tasks::CreateTaskResult =
serde_json::from_value(result.clone())?;
return Ok(ToolCallOutcome::Task(task));
}
crate::protocol::v2026_07_28::parse_call_tool(result).map(ToolCallOutcome::Completed)
})
.await
}
#[cfg(feature = "ext-tasks")]
pub async fn call_tool_or_task_with_input_responses(
&self,
name: &str,
arguments: Value,
input_responses: Value,
request_state: Option<String>,
) -> McpClientResult<ToolCallOutcome> {
if self.negotiated_version().await != Some(crate::version::McpVersion::V2026_07_28) {
return Err(crate::error::ProtocolError::MethodNotFound(
"the Tasks extension requires a 2026-07-28 connection".to_string(),
)
.into());
}
let mut extra = json!({
"name": name,
"arguments": arguments,
"inputResponses": input_responses,
});
if let (Some(obj), Some(state)) = (extra.as_object_mut(), request_state) {
obj.insert("requestState".to_string(), Value::String(state));
}
self.send_2026_07_28_with_extra_headers("tools/call", extra, &[], |result| {
if result.get("resultType").and_then(|v| v.as_str())
== Some(turul_mcp_ext_tasks::RESULT_TYPE_TASK)
{
let task: turul_mcp_ext_tasks::CreateTaskResult =
serde_json::from_value(result.clone())?;
return Ok(ToolCallOutcome::Task(task));
}
crate::protocol::v2026_07_28::parse_call_tool(result).map(ToolCallOutcome::Completed)
})
.await
}
#[cfg(feature = "ext-tasks")]
pub async fn task_get(
&self,
task_id: &str,
) -> McpClientResult<turul_mcp_ext_tasks::DetailedTask> {
self.send_2026_07_28("tasks/get", json!({ "taskId": task_id }), |result| {
let r: turul_mcp_ext_tasks::GetTaskResult = serde_json::from_value(result.clone())?;
Ok(r.task)
})
.await
}
#[cfg(feature = "ext-tasks")]
pub async fn task_update(&self, task_id: &str, input_responses: Value) -> McpClientResult<()> {
self.send_2026_07_28(
"tasks/update",
json!({ "taskId": task_id, "inputResponses": input_responses }),
|_| Ok(()),
)
.await
}
#[cfg(feature = "ext-tasks")]
pub async fn task_cancel(&self, task_id: &str) -> McpClientResult<()> {
self.send_2026_07_28("tasks/cancel", json!({ "taskId": task_id }), |_| Ok(()))
.await
}
#[cfg(feature = "ext-tasks")]
pub async fn task_wait(
&self,
task_id: &str,
) -> McpClientResult<turul_mcp_ext_tasks::DetailedTask> {
loop {
let task = self.task_get(task_id).await?;
if task.status().is_terminal() {
return Ok(task);
}
let interval_ms = task
.fields()
.poll_interval_ms
.unwrap_or(500.0)
.clamp(50.0, 30_000.0);
tokio::time::sleep(std::time::Duration::from_millis(interval_ms as u64)).await;
}
}
pub async fn list_resources(&self) -> McpClientResult<Vec<Resource>> {
{
let cache = self.cached_resources.read().await;
if let Some(ref resources) = *cache {
debug!(count = resources.len(), "Returning cached resources");
return Ok(resources.clone());
}
}
let resources = self.fetch_resources().await?;
{
let mut cache = self.cached_resources.write().await;
*cache = Some(resources.clone());
}
Ok(resources)
}
pub async fn refresh_resources(&self) -> McpClientResult<Vec<Resource>> {
let resources = self.fetch_resources().await?;
{
let mut cache = self.cached_resources.write().await;
*cache = Some(resources.clone());
}
Ok(resources)
}
async fn fetch_resources(&self) -> McpClientResult<Vec<Resource>> {
debug!("Fetching resources from server");
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
return self
.send_2026_07_28(
"resources/list",
json!({}),
crate::protocol::v2026_07_28::parse_list_resources,
)
.await;
}
let request = self.build_request("resources/list", json!({}));
let response = self.send_request_internal(request).await?;
let resources_response: ListResourcesResult =
serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?;
debug!(
count = resources_response.resources.len(),
"Retrieved resources"
);
Ok(resources_response.resources)
}
pub async fn list_resources_paginated(
&self,
cursor: Option<Cursor>,
) -> McpClientResult<ListResourcesResult> {
debug!("Listing resources with pagination");
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
let extra = match cursor {
Some(ref c) => json!({ "cursor": c.as_str() }),
None => json!({}),
};
return self
.send_2026_07_28(
"resources/list",
extra,
crate::protocol::v2026_07_28::parse_list_resources_result,
)
.await;
}
let request_params = if let Some(cursor) = cursor {
json!({ "cursor": cursor.as_str() })
} else {
json!({})
};
let request = self.build_request("resources/list", request_params);
let response = self.send_request_internal(request).await?;
let resources_response: ListResourcesResult =
serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?;
debug!(
count = resources_response.resources.len(),
has_cursor = resources_response.next_cursor.is_some(),
"Retrieved resources with pagination"
);
Ok(resources_response)
}
pub async fn read_resource(
&self,
uri: &str,
) -> McpClientResult<Vec<turul_mcp_protocol_2025_11_25::ResourceContent>> {
debug!(uri = uri, "Reading resource");
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
let r = self
.send_2026_07_28(
"resources/read",
json!({ "uri": uri }),
crate::protocol::v2026_07_28::parse_read_resource,
)
.await?;
return Ok(r.contents);
}
let request = self.build_request("resources/read", json!({ "uri": uri }));
let response = self.send_request_internal(request).await?;
let read_response: ReadResourceResult =
serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?;
debug!(
uri = uri,
content_count = read_response.contents.len(),
"Resource read completed"
);
Ok(read_response.contents)
}
pub async fn list_resource_templates(&self) -> McpClientResult<Vec<ResourceTemplate>> {
debug!("Listing resource templates");
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
return self
.send_2026_07_28(
"resources/templates/list",
json!({}),
crate::protocol::v2026_07_28::parse_list_resource_templates,
)
.await;
}
let request = self.build_request("resources/templates/list", json!({}));
let response = self.send_request_internal(request).await?;
let templates_response: ListResourceTemplatesResult =
serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?;
debug!(
count = templates_response.resource_templates.len(),
"Retrieved resource templates"
);
Ok(templates_response.resource_templates)
}
pub async fn list_resource_templates_paginated(
&self,
cursor: Option<Cursor>,
) -> McpClientResult<ListResourceTemplatesResult> {
debug!("Listing resource templates with pagination");
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
let extra = match cursor {
Some(ref c) => json!({ "cursor": c.as_str() }),
None => json!({}),
};
return self
.send_2026_07_28(
"resources/templates/list",
extra,
crate::protocol::v2026_07_28::parse_list_resource_templates_result,
)
.await;
}
let request_params = if let Some(cursor) = cursor {
json!({ "cursor": cursor.as_str() })
} else {
json!({})
};
let request = self.build_request("resources/templates/list", request_params);
let response = self.send_request_internal(request).await?;
let templates_response: ListResourceTemplatesResult =
serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?;
debug!(
count = templates_response.resource_templates.len(),
has_cursor = templates_response.next_cursor.is_some(),
"Retrieved resource templates with pagination"
);
Ok(templates_response)
}
pub async fn list_prompts(&self) -> McpClientResult<Vec<Prompt>> {
{
let cache = self.cached_prompts.read().await;
if let Some(ref prompts) = *cache {
debug!(count = prompts.len(), "Returning cached prompts");
return Ok(prompts.clone());
}
}
let prompts = self.fetch_prompts().await?;
{
let mut cache = self.cached_prompts.write().await;
*cache = Some(prompts.clone());
}
Ok(prompts)
}
pub async fn refresh_prompts(&self) -> McpClientResult<Vec<Prompt>> {
let prompts = self.fetch_prompts().await?;
{
let mut cache = self.cached_prompts.write().await;
*cache = Some(prompts.clone());
}
Ok(prompts)
}
async fn fetch_prompts(&self) -> McpClientResult<Vec<Prompt>> {
debug!("Fetching prompts from server");
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
return self
.send_2026_07_28(
"prompts/list",
json!({}),
crate::protocol::v2026_07_28::parse_list_prompts,
)
.await;
}
let request = self.build_request("prompts/list", json!({}));
let response = self.send_request_internal(request).await?;
let prompts_response: ListPromptsResult =
serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?;
debug!(count = prompts_response.prompts.len(), "Retrieved prompts");
Ok(prompts_response.prompts)
}
pub async fn list_prompts_paginated(
&self,
cursor: Option<Cursor>,
) -> McpClientResult<ListPromptsResult> {
debug!("Listing prompts with pagination");
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
let extra = match cursor {
Some(ref c) => json!({ "cursor": c.as_str() }),
None => json!({}),
};
return self
.send_2026_07_28(
"prompts/list",
extra,
crate::protocol::v2026_07_28::parse_list_prompts_result,
)
.await;
}
let request_params = if let Some(cursor) = cursor {
json!({ "cursor": cursor.as_str() })
} else {
json!({})
};
let request = self.build_request("prompts/list", request_params);
let response = self.send_request_internal(request).await?;
let prompts_response: ListPromptsResult =
serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?;
debug!(
count = prompts_response.prompts.len(),
has_cursor = prompts_response.next_cursor.is_some(),
"Retrieved prompts with pagination"
);
Ok(prompts_response)
}
pub async fn get_prompt(
&self,
name: &str,
arguments: Option<Value>,
) -> McpClientResult<GetPromptResult> {
debug!(prompt = name, "Getting prompt");
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
let mut extra = json!({ "name": name });
if let Some(ref args) = arguments {
extra["arguments"] = args.clone();
}
return self
.send_2026_07_28(
"prompts/get",
extra,
crate::protocol::v2026_07_28::parse_get_prompt,
)
.await;
}
let mut params = json!({
"name": name
});
if let Some(args) = arguments {
params["arguments"] = args;
}
let request = self.build_request("prompts/get", params);
let response = self.send_request_internal(request).await?;
let prompt_response: GetPromptResult =
serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?;
debug!(
prompt = name,
message_count = prompt_response.messages.len(),
"Prompt retrieved"
);
Ok(prompt_response)
}
pub async fn complete(
&self,
reference: CompletionReference,
argument: CompleteArgument,
context: Option<CompletionContext>,
) -> McpClientResult<CompleteResult> {
debug!(argument = %argument.name, "Requesting completions");
let mut params = json!({
"ref": serde_json::to_value(&reference)?,
"argument": serde_json::to_value(&argument)?,
});
if let Some(ref context) = context {
params["context"] = serde_json::to_value(context)?;
}
#[cfg(any(feature = "client-bilingual", feature = "client-2026-07-28-only"))]
if self.negotiated_version().await == Some(crate::version::McpVersion::V2026_07_28) {
return self
.send_2026_07_28(
"completion/complete",
params,
crate::protocol::v2026_07_28::parse_complete,
)
.await;
}
let request = self.build_request("completion/complete", params);
let response = self.send_request_internal(request).await?;
let result: CompleteResult =
serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?;
debug!(
count = result.completion.values.len(),
"Completions received"
);
Ok(result)
}
pub async fn cancel_request(
&self,
request_id: &str,
reason: Option<&str>,
) -> McpClientResult<()> {
debug!(request_id, "Cancelling request");
let mut params = json!({ "requestId": request_id });
if let Some(reason) = reason {
params["reason"] = json!(reason);
}
self.send_notification_internal(Self::build_notification("notifications/cancelled", params))
.await
}
pub async fn ping(&self) -> McpClientResult<()> {
debug!("Sending ping");
self.reject_if_2026_07_28("ping").await?;
let request = self.build_request("ping", json!({}));
self.send_request_internal(request).await?;
debug!("Ping successful");
Ok(())
}
pub async fn get_task(&self, task_id: &str) -> McpClientResult<Task> {
debug!(task_id = task_id, "Getting task");
self.reject_if_2026_07_28("tasks/get").await?;
let request = self.build_request("tasks/get", json!({ "taskId": task_id }));
let response = self.send_request_internal(request).await?;
let task_response: GetTaskResult =
serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?;
debug!(task_id = task_id, status = ?task_response.task.status, "Task retrieved");
Ok(task_response.task)
}
pub async fn list_tasks(&self) -> McpClientResult<Vec<Task>> {
debug!("Listing tasks");
self.reject_if_2026_07_28("tasks/list").await?;
let request = self.build_request("tasks/list", json!({}));
let response = self.send_request_internal(request).await?;
let tasks_response: ListTasksResult =
serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?;
debug!(count = tasks_response.tasks.len(), "Retrieved tasks");
Ok(tasks_response.tasks)
}
pub async fn list_tasks_paginated(
&self,
cursor: Option<Cursor>,
) -> McpClientResult<ListTasksResult> {
debug!("Listing tasks with pagination");
self.reject_if_2026_07_28("tasks/list").await?;
let request_params = if let Some(cursor) = cursor {
json!({ "cursor": cursor.as_str() })
} else {
json!({})
};
let request = self.build_request("tasks/list", request_params);
let response = self.send_request_internal(request).await?;
let tasks_response: ListTasksResult =
serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?;
debug!(
count = tasks_response.tasks.len(),
has_cursor = tasks_response.next_cursor.is_some(),
"Retrieved tasks with pagination"
);
Ok(tasks_response)
}
pub async fn cancel_task(&self, task_id: &str) -> McpClientResult<Task> {
debug!(task_id = task_id, "Cancelling task");
self.reject_if_2026_07_28("tasks/cancel").await?;
let request = self.build_request("tasks/cancel", json!({ "taskId": task_id }));
let response = self.send_request_internal(request).await?;
let cancel_response: CancelTaskResult =
serde_json::from_value(response.get("result").cloned().unwrap_or(Value::Null))?;
debug!(task_id = task_id, status = ?cancel_response.task.status, "Task cancelled");
Ok(cancel_response.task)
}
pub async fn get_task_result(&self, task_id: &str) -> McpClientResult<Value> {
debug!(task_id = task_id, "Getting task result");
self.reject_if_2026_07_28("tasks/result").await?;
let request = self.build_request("tasks/result", json!({ "taskId": task_id }));
let response = timeout(
self.config.timeouts.long_operation,
self.send_request_internal(request),
)
.await
.map_err(|_| McpClientError::Timeout)??;
let result = response.get("result").cloned().unwrap_or(Value::Null);
debug!(task_id = task_id, "Task result retrieved");
Ok(result)
}
pub async fn call_tool_with_task(
&self,
name: &str,
arguments: Value,
ttl_ms: Option<i64>,
) -> McpClientResult<ToolCallResponse> {
debug!(tool = name, "Calling tool with task augmentation");
self.reject_if_2026_07_28("tasks (task-augmented tools/call)")
.await?;
let mut params = json!({
"name": name,
"arguments": arguments,
"task": {}
});
if let Some(ttl) = ttl_ms {
params["task"]["ttl"] = json!(ttl);
}
let request = self.build_request("tools/call", params);
let response = self.send_request_internal(request).await?;
let result = response.get("result").cloned().unwrap_or(Value::Null);
if result.get("task").is_some() {
let task_result: CreateTaskResult = serde_json::from_value(result)?;
debug!(
tool = name,
task_id = task_result.task.task_id,
"Tool call created task"
);
Ok(ToolCallResponse::TaskCreated(task_result.task))
} else {
let call_result: CallToolResult = serde_json::from_value(result)?;
debug!(
tool = name,
is_error = call_result.is_error,
"Tool call completed synchronously"
);
Ok(ToolCallResponse::Immediate(call_result))
}
}
pub async fn stream_handler(&self) -> tokio::sync::MutexGuard<'_, StreamHandler> {
self.stream_handler.lock().await
}
pub async fn invalidate_caches(&self) {
*self.cached_tools.write().await = None;
*self.cached_resources.write().await = None;
*self.cached_prompts.write().await = None;
debug!("All list caches invalidated");
}
pub async fn session_info(&self) -> crate::session::SessionInfo {
self.session.session_info().await
}
pub async fn transport_stats(&self) -> crate::transport::TransportStatistics {
self.transport.statistics()
}
}
#[derive(Debug, Clone)]
pub struct ConnectionStatus {
pub transport_connected: bool,
pub session_state: SessionState,
pub transport_type: crate::transport::TransportType,
pub endpoint: String,
pub session_id: Option<String>,
pub protocol_version: Option<String>,
}
impl ConnectionStatus {
pub fn is_ready(&self) -> bool {
self.transport_connected && matches!(self.session_state, SessionState::Active)
}
pub fn summary(&self) -> String {
let session_display = match &self.session_id {
Some(id) => &id[..id.len().min(8)],
None => "None",
};
format!(
"{} transport to {} - Session {} ({})",
self.transport_type, self.endpoint, session_display, self.session_state
)
}
}
#[derive(Debug)]
pub enum ToolCallResponse {
Immediate(CallToolResult),
TaskCreated(Task),
}
impl ToolCallResponse {
pub fn is_task(&self) -> bool {
matches!(self, ToolCallResponse::TaskCreated(_))
}
pub fn task(&self) -> Option<&Task> {
match self {
ToolCallResponse::TaskCreated(task) => Some(task),
_ => None,
}
}
pub fn immediate_result(&self) -> Option<&CallToolResult> {
match self {
ToolCallResponse::Immediate(result) => Some(result),
_ => None,
}
}
}
pub struct McpClientBuilder {
transport: Option<BoxedTransport>,
url: Option<String>,
config: Option<ClientConfig>,
notification_callback: Option<NotificationCallback>,
}
impl McpClientBuilder {
pub fn new() -> Self {
Self {
transport: None,
url: None,
config: None,
notification_callback: None,
}
}
pub fn with_transport(mut self, transport: BoxedTransport) -> Self {
self.transport = Some(transport);
self.url = None; self
}
pub fn with_url(mut self, url: &str) -> McpClientResult<Self> {
let parsed = url::Url::parse(url).map_err(|e| {
crate::error::TransportError::ConnectionFailed(format!("Invalid URL: {}", e))
})?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(crate::error::TransportError::ConnectionFailed(format!(
"Invalid scheme: {}",
parsed.scheme()
))
.into());
}
self.url = Some(url.to_string());
self.transport = None; Ok(self)
}
pub fn with_config(mut self, config: ClientConfig) -> Self {
self.config = Some(config);
self
}
pub fn on_notification<F>(mut self, callback: F) -> Self
where
F: Fn(&str, Option<&Value>) + Send + Sync + 'static,
{
self.notification_callback = Some(Arc::new(callback));
self
}
pub fn build(self) -> McpClient {
let config = self.config.unwrap_or_default();
let transport = if let Some(transport) = self.transport {
transport
} else if let Some(ref url) = self.url {
let transport_type = crate::transport::detect_transport_type(url)
.expect("URL was validated in with_url() but detection failed");
match transport_type {
crate::transport::TransportType::Http => Box::new(
crate::transport::http::HttpTransport::with_config(url, &config.connection)
.expect(
"URL was validated in with_url() but transport construction failed",
),
)
as crate::transport::BoxedTransport,
crate::transport::TransportType::Sse => {
Box::new(
#[allow(deprecated)] crate::transport::sse::SseTransport::new(url)
.expect("URL was validated in with_url() but SSE construction failed"),
)
}
}
} else {
panic!("Transport must be set via with_transport() or with_url() before building");
};
McpClient::new_with_callback(transport, config, self.notification_callback)
}
}
impl Default for McpClientBuilder {
fn default() -> Self {
Self::new()
}
}
fn value_to_request_params(params: Value) -> Option<RequestParams> {
match params {
Value::Null => None,
Value::Object(map) => Some(RequestParams::Object(map.into_iter().collect())),
Value::Array(arr) => Some(RequestParams::Array(arr)),
other => unreachable!(
"MCP client requests use object or null params; got scalar: {:?}",
other
),
}
}
#[cfg(feature = "ext-tasks")]
#[derive(Debug)]
pub enum ToolCallOutcome {
Completed(CallToolResult),
Task(turul_mcp_ext_tasks::CreateTaskResult),
}
pub struct SubscriptionStream {
pub honored: Value,
pub subscription_id: Option<String>,
request_id: Option<String>,
receiver: tokio::sync::mpsc::UnboundedReceiver<Value>,
}
impl SubscriptionStream {
pub async fn next(&mut self) -> Option<Value> {
self.receiver.recv().await
}
pub fn request_id(&self) -> Option<&str> {
self.request_id.as_deref()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transport::http::HttpTransport;
use crate::transport::{
ConnectionInfo, EventReceiver, ServerEvent, TransportCapabilities, TransportResponse,
TransportStatistics, TransportType,
};
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use tokio::sync::mpsc;
#[tokio::test]
async fn test_client_builder() {
let transport = HttpTransport::new("http://localhost:8080/mcp").unwrap();
let client = McpClientBuilder::new()
.with_transport(Box::new(transport))
.build();
assert!(!client.is_ready().await);
}
#[test]
fn test_connection_status() {
let status = ConnectionStatus {
transport_connected: true,
session_state: SessionState::Active,
transport_type: crate::transport::TransportType::Http,
endpoint: "http://localhost:8080/mcp".to_string(),
session_id: Some("session123".to_string()),
protocol_version: Some("2025-11-25".to_string()),
};
assert!(status.is_ready());
assert!(status.summary().contains("HTTP transport"));
}
struct MockTransport {
#[cfg_attr(
not(any(feature = "client-bilingual", feature = "client-2025-11-25-only")),
allow(dead_code)
)]
event_tx: mpsc::UnboundedSender<ServerEvent>,
event_rx: parking_lot::Mutex<Option<mpsc::UnboundedReceiver<ServerEvent>>>,
notifications: Arc<tokio::sync::Mutex<Vec<Value>>>,
connected: AtomicBool,
delete_count: Arc<std::sync::atomic::AtomicU32>,
}
impl MockTransport {
fn new() -> (Self, Arc<tokio::sync::Mutex<Vec<Value>>>) {
let (event_tx, event_rx) = mpsc::unbounded_channel();
let notifications = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let mock = Self {
event_tx,
event_rx: parking_lot::Mutex::new(Some(event_rx)),
notifications: Arc::clone(¬ifications),
connected: AtomicBool::new(false),
delete_count: Arc::new(std::sync::atomic::AtomicU32::new(0)),
};
(mock, notifications)
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
fn event_sender(&self) -> mpsc::UnboundedSender<ServerEvent> {
self.event_tx.clone()
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
fn delete_count_handle(&self) -> Arc<std::sync::atomic::AtomicU32> {
Arc::clone(&self.delete_count)
}
}
#[async_trait]
impl crate::transport::Transport for MockTransport {
fn transport_type(&self) -> TransportType {
TransportType::Http
}
fn capabilities(&self) -> TransportCapabilities {
TransportCapabilities {
streaming: true,
bidirectional: true,
server_events: true,
max_message_size: None,
persistent: true,
}
}
async fn connect(&self) -> McpClientResult<()> {
self.connected.store(true, AtomicOrdering::SeqCst);
Ok(())
}
async fn disconnect(&self) -> McpClientResult<()> {
self.connected.store(false, AtomicOrdering::SeqCst);
Ok(())
}
fn is_connected(&self) -> bool {
self.connected.load(AtomicOrdering::SeqCst)
}
async fn send_request(&self, _request: Value) -> McpClientResult<Value> {
Ok(json!({"jsonrpc": "2.0", "result": {}}))
}
async fn send_request_with_headers(
&self,
request: Value,
) -> McpClientResult<TransportResponse> {
if request.get("method").and_then(|m| m.as_str()) == Some("server/discover") {
return Ok(TransportResponse::new(
json!({"jsonrpc": "2.0", "id": "req_0", "error": {"code": -32601, "message": "Method not found"}}),
HashMap::new(),
));
}
let mut headers = HashMap::new();
headers.insert("mcp-session-id".to_string(), "mock-session-123".to_string());
Ok(TransportResponse::new(
json!({
"jsonrpc": "2.0",
"id": "req_0",
"result": {
"protocolVersion": "2025-11-25",
"capabilities": {
"tools": { "listChanged": false }
},
"serverInfo": {
"name": "mock-server",
"version": "1.0.0"
}
}
}),
headers,
))
}
async fn send_notification(&self, notification: Value) -> McpClientResult<()> {
self.notifications.lock().await.push(notification);
Ok(())
}
async fn send_delete(&self, _session_id: &str) -> McpClientResult<()> {
self.delete_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
}
fn set_session_id(&self, _session_id: String) {}
fn clear_session_id(&self) {}
async fn start_event_listener(&self) -> McpClientResult<EventReceiver> {
self.event_rx
.lock()
.take()
.ok_or_else(|| McpClientError::generic("Event listener already started"))
}
fn connection_info(&self) -> ConnectionInfo {
ConnectionInfo {
transport_type: TransportType::Http,
endpoint: "mock://test".to_string(),
connected: self.is_connected(),
capabilities: self.capabilities(),
metadata: Value::Null,
}
}
fn statistics(&self) -> TransportStatistics {
TransportStatistics::default()
}
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_client_response_consumer_pipeline() {
let (mock, notifications) = MockTransport::new();
let event_sender = mock.event_sender();
let client = McpClientBuilder::new()
.with_transport(Box::new(mock))
.build();
client.connect().await.unwrap();
{
let handler = client.stream_handler().await;
handler.on_request(|request| {
let method = request.get("method").and_then(|m| m.as_str()).unwrap_or("");
match method {
"sampling/createMessage" => Ok(json!({
"role": "assistant",
"content": { "type": "text", "text": "mock response" },
"model": "test-model"
})),
_ => Err(format!("Unsupported: {}", method)),
}
});
}
event_sender
.send(ServerEvent::Request(json!({
"jsonrpc": "2.0",
"id": "srv-req-42",
"method": "sampling/createMessage",
"params": {
"messages": [{"role": "user", "content": {"type": "text", "text": "Hi"}}],
"maxTokens": 100
}
})))
.unwrap();
let response = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
let notifs = notifications.lock().await;
let responses: Vec<&Value> =
notifs.iter().filter(|n| n.get("id").is_some()).collect();
if !responses.is_empty() {
return responses[0].clone();
}
drop(notifs);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
})
.await
.expect("timed out waiting for response to reach transport");
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], "srv-req-42");
assert!(
response.get("error").is_none(),
"should not have error field"
);
assert_eq!(response["result"]["role"], "assistant");
assert_eq!(response["result"]["model"], "test-model");
client.disconnect().await.unwrap();
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_client_response_consumer_pipeline_error() {
let (mock, notifications) = MockTransport::new();
let event_sender = mock.event_sender();
let client = McpClientBuilder::new()
.with_transport(Box::new(mock))
.build();
client.connect().await.unwrap();
{
let handler = client.stream_handler().await;
handler.on_request(|_req| Err("not supported".to_string()));
}
event_sender
.send(ServerEvent::Request(json!({
"jsonrpc": "2.0",
"id": 99,
"method": "elicitation/create",
"params": {}
})))
.unwrap();
let response = tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
let notifs = notifications.lock().await;
let responses: Vec<&Value> =
notifs.iter().filter(|n| n.get("id").is_some()).collect();
if !responses.is_empty() {
return responses[0].clone();
}
drop(notifs);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
})
.await
.expect("timed out waiting for error response");
assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 99);
assert_eq!(response["error"]["code"], -32603);
assert!(
response["error"]["message"]
.as_str()
.unwrap()
.contains("not supported")
);
client.disconnect().await.unwrap();
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_disconnect_clears_session_so_drop_is_noop() {
let (mock, _notifications) = MockTransport::new();
let delete_count = mock.delete_count_handle();
let client = McpClientBuilder::new()
.with_transport(Box::new(mock))
.build();
client.connect().await.unwrap();
client.disconnect().await.unwrap();
assert_eq!(
delete_count.load(std::sync::atomic::Ordering::SeqCst),
1,
"explicit disconnect must send exactly one DELETE"
);
drop(client);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(
delete_count.load(std::sync::atomic::Ordering::SeqCst),
1,
"Drop after disconnect must NOT send a second DELETE"
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_drop_without_disconnect_still_fires_delete() {
let (mock, _notifications) = MockTransport::new();
let delete_count = mock.delete_count_handle();
let client = McpClientBuilder::new()
.with_transport(Box::new(mock))
.build();
client.connect().await.unwrap();
drop(client);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert_eq!(
delete_count.load(std::sync::atomic::Ordering::SeqCst),
1,
"Drop without prior disconnect must send DELETE"
);
}
#[tokio::test]
async fn test_builder_with_sse_url_yields_sse_transport() {
let client = McpClientBuilder::new()
.with_url("http://localhost:9999/sse")
.unwrap()
.build();
let status = client.connection_status().await;
assert_eq!(
status.transport_type,
crate::transport::TransportType::Sse,
"URL with /sse path must yield SSE transport"
);
}
#[tokio::test]
async fn test_builder_with_mcp_url_yields_http_transport() {
let client = McpClientBuilder::new()
.with_url("http://localhost:9999/mcp")
.unwrap()
.build();
let status = client.connection_status().await;
assert_eq!(
status.transport_type,
crate::transport::TransportType::Http,
"Non-SSE URL must yield HTTP transport"
);
}
use std::collections::VecDeque;
use std::sync::atomic::AtomicU32;
#[allow(dead_code)]
struct StatefulMockTransport {
init_responses: Arc<std::sync::Mutex<VecDeque<McpClientResult<TransportResponse>>>>,
request_responses: Arc<std::sync::Mutex<VecDeque<McpClientResult<Value>>>>,
set_session_ids: Arc<std::sync::Mutex<Vec<String>>>,
clear_count: Arc<AtomicU32>,
event_tx: Option<mpsc::UnboundedSender<ServerEvent>>,
event_rx: parking_lot::Mutex<Option<mpsc::UnboundedReceiver<ServerEvent>>>,
caps: TransportCapabilities,
connected: AtomicBool,
}
impl StatefulMockTransport {
fn new() -> Self {
let (event_tx, event_rx) = mpsc::unbounded_channel();
Self {
init_responses: Arc::new(std::sync::Mutex::new(VecDeque::new())),
request_responses: Arc::new(std::sync::Mutex::new(VecDeque::new())),
set_session_ids: Arc::new(std::sync::Mutex::new(Vec::new())),
clear_count: Arc::new(AtomicU32::new(0)),
event_tx: Some(event_tx),
event_rx: parking_lot::Mutex::new(Some(event_rx)),
caps: TransportCapabilities {
streaming: true,
bidirectional: true,
server_events: true,
max_message_size: None,
persistent: true,
},
connected: AtomicBool::new(false),
}
}
#[allow(dead_code)]
fn push_init_response(&mut self, resp: McpClientResult<TransportResponse>) {
self.init_responses.lock().unwrap().push_back(resp);
}
fn push_request_response(&mut self, resp: McpClientResult<Value>) {
self.request_responses.lock().unwrap().push_back(resp);
}
#[allow(dead_code)]
fn make_init_response(
session_id: Option<&str>,
protocol_version: &str,
) -> TransportResponse {
let mut headers = HashMap::new();
if let Some(sid) = session_id {
headers.insert("mcp-session-id".to_string(), sid.to_string());
}
TransportResponse::new(
json!({
"jsonrpc": "2.0",
"id": "req_0",
"result": {
"protocolVersion": protocol_version,
"capabilities": {
"tools": { "listChanged": false }
},
"serverInfo": {
"name": "stateful-mock",
"version": "1.0.0"
}
}
}),
headers,
)
}
}
#[async_trait]
impl crate::transport::Transport for StatefulMockTransport {
fn transport_type(&self) -> TransportType {
TransportType::Http
}
fn capabilities(&self) -> TransportCapabilities {
self.caps.clone()
}
async fn connect(&self) -> McpClientResult<()> {
self.connected.store(true, AtomicOrdering::SeqCst);
Ok(())
}
async fn disconnect(&self) -> McpClientResult<()> {
self.connected.store(false, AtomicOrdering::SeqCst);
Ok(())
}
fn is_connected(&self) -> bool {
self.connected.load(AtomicOrdering::SeqCst)
}
async fn send_request(&self, _request: Value) -> McpClientResult<Value> {
self.request_responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| {
Err(McpClientError::generic(
"StatefulMockTransport: no more request responses queued",
))
})
}
async fn send_request_with_headers(
&self,
request: Value,
) -> McpClientResult<TransportResponse> {
if request.get("method").and_then(|m| m.as_str()) == Some("server/discover") {
return Ok(TransportResponse::new(
json!({"jsonrpc": "2.0", "id": "req_0", "error": {"code": -32601, "message": "Method not found"}}),
HashMap::new(),
));
}
self.init_responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| {
Err(McpClientError::generic(
"StatefulMockTransport: no more init responses queued",
))
})
}
async fn send_notification(&self, _notification: Value) -> McpClientResult<()> {
Ok(())
}
async fn send_delete(&self, _session_id: &str) -> McpClientResult<()> {
Ok(())
}
fn set_session_id(&self, session_id: String) {
self.set_session_ids.lock().unwrap().push(session_id);
}
fn clear_session_id(&self) {
self.clear_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
async fn start_event_listener(&self) -> McpClientResult<EventReceiver> {
self.event_rx
.lock()
.take()
.ok_or_else(|| McpClientError::generic("Event listener already started"))
}
fn connection_info(&self) -> ConnectionInfo {
ConnectionInfo {
transport_type: TransportType::Http,
endpoint: "stateful-mock://test".to_string(),
connected: self.is_connected(),
capabilities: self.caps.clone(),
metadata: Value::Null,
}
}
fn statistics(&self) -> TransportStatistics {
TransportStatistics::default()
}
}
#[tokio::test]
async fn test_stateful_mock_transport_sequences() {
use crate::transport::Transport;
let mut transport = StatefulMockTransport::new();
transport.push_request_response(Ok(json!({"result": "first"})));
transport.push_request_response(Ok(json!({"result": "second"})));
let r1 = transport.send_request(json!({})).await.unwrap();
assert_eq!(r1["result"], "first");
let r2 = transport.send_request(json!({})).await.unwrap();
assert_eq!(r2["result"], "second");
assert!(transport.send_request(json!({})).await.is_err());
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
use crate::config::RetryConfig;
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
use std::sync::atomic::Ordering;
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
use std::time::Duration;
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
fn fast_retry_config(max_attempts: u32) -> ClientConfig {
ClientConfig {
retry: RetryConfig {
max_attempts,
initial_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(1),
..Default::default()
},
..Default::default()
}
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_404_reinitialize_clears_stale_session_id() {
let mut transport = StatefulMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-AAA"),
"2025-11-25",
)));
transport.push_request_response(Err(McpClientError::Transport(
crate::error::TransportError::HttpStatus {
status: 404,
message: "Not Found".to_string(),
},
)));
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-BBB"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_1",
"result": { "tools": [] }
})));
let clear_count = transport.clear_count.clone();
let set_ids = transport.set_session_ids.clone();
let client = McpClient::new(Box::new(transport), fast_retry_config(3));
client.connect().await.unwrap();
let result = client.list_tools().await;
assert!(
result.is_ok(),
"Request should succeed after 404 re-initialization: {:?}",
result.err()
);
assert_eq!(
clear_count.load(Ordering::SeqCst),
1,
"clear_session_id must be called exactly once during 404 recovery"
);
let ids = set_ids.lock().unwrap();
assert!(
ids.contains(&"session-BBB".to_string()),
"New session ID 'session-BBB' should be set after re-initialization, got: {:?}",
*ids
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_404_on_last_retry_attempt_still_recovers() {
let mut transport = StatefulMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-AAA"),
"2025-11-25",
)));
transport.push_request_response(Err(McpClientError::Transport(
crate::error::TransportError::HttpStatus {
status: 404,
message: "Not Found".to_string(),
},
)));
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-BBB"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_1",
"result": { "tools": [] }
})));
let client = McpClient::new(Box::new(transport), fast_retry_config(2));
client.connect().await.unwrap();
let result = client.list_tools().await;
assert!(
result.is_ok(),
"404 recovery should work even with max_attempts=2: {:?}",
result.err()
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_404_reinit_failure_surfaces_original_error() {
let mut transport = StatefulMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-AAA"),
"2025-11-25",
)));
transport.push_request_response(Err(McpClientError::Transport(
crate::error::TransportError::HttpStatus {
status: 404,
message: "Session gone".to_string(),
},
)));
transport.push_init_response(Err(McpClientError::Transport(
crate::error::TransportError::ConnectionFailed("Connection refused".to_string()),
)));
let client = McpClient::new(Box::new(transport), fast_retry_config(3));
client.connect().await.unwrap();
let result = client.list_tools().await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.is_session_expired(),
"Should surface original 404 error, got: {}",
err
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_optional_session_id_no_hard_failure() {
let mut transport = StatefulMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
None, "2025-11-25",
)));
let client = McpClient::new(Box::new(transport), ClientConfig::default());
let result = client.connect().await;
assert!(
result.is_ok(),
"connect() must succeed without Mcp-Session-Id: {:?}",
result.err()
);
assert!(
client.is_ready().await,
"Client must be ready after stateless init"
);
let status = client.connection_status().await;
assert!(
status.session_id.is_none(),
"Session ID should be None for stateless server, got: {:?}",
status.session_id
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_unsupported_protocol_version_rejected() {
let mut transport = StatefulMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-123"),
"2099-01-01",
)));
let client = McpClient::new(Box::new(transport), ClientConfig::default());
let result = client.connect().await;
assert!(
result.is_err(),
"connect() must fail for unsupported protocol version"
);
let err = result.unwrap_err();
let err_msg = err.to_string();
assert!(
err_msg.contains("2099-01-01"),
"Error should mention server's version '2099-01-01', got: {}",
err_msg
);
assert!(
err_msg.contains("2025-11-25"),
"Error should mention supported version '2025-11-25', got: {}",
err_msg
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_jsonrpc_error_surfaces_as_server_error_with_code_message_data() {
let mut transport = StatefulMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-1"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_1",
"error": {
"code": -32602,
"message": "Invalid params",
"data": {"detail": "missing field 'name'"}
}
})));
let client = McpClient::new(Box::new(transport), ClientConfig::default());
client.connect().await.unwrap();
let result = client.list_tools().await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.error_code(), Some(-32602));
assert!(err.to_string().contains("Invalid params"));
if let McpClientError::ServerError { data, .. } = &err {
assert!(data.is_some());
assert_eq!(data.as_ref().unwrap()["detail"], "missing field 'name'");
} else {
panic!("Expected ServerError, got: {:?}", err);
}
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_jsonrpc_error_without_data_field() {
let mut transport = StatefulMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-1"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_1",
"error": {
"code": -32600,
"message": "Invalid Request"
}
})));
let client = McpClient::new(Box::new(transport), ClientConfig::default());
client.connect().await.unwrap();
let result = client.list_tools().await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.error_code(), Some(-32600));
if let McpClientError::ServerError { data, .. } = &err {
assert!(data.is_none(), "data should be None when server omits it");
} else {
panic!("Expected ServerError, got: {:?}", err);
}
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_call_tool_malformed_response_returns_error() {
let mut transport = StatefulMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-1"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_1",
"result": {"unexpected": "shape"}
})));
let client = McpClient::new(Box::new(transport), ClientConfig::default());
client.connect().await.unwrap();
let result = client.call_tool("test", json!({})).await;
assert!(
result.is_err(),
"Malformed response should return error, not panic"
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_get_prompt_malformed_response_returns_error() {
let mut transport = StatefulMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-1"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_1",
"result": {"wrong": "format"}
})));
let client = McpClient::new(Box::new(transport), ClientConfig::default());
client.connect().await.unwrap();
let result = client.get_prompt("test", None).await;
assert!(
result.is_err(),
"Malformed response should return error, not panic"
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_list_tools_caches_result() {
let mut transport = StatefulMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-1"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_1",
"result": {
"tools": [
{"name": "tool_a", "description": "Tool A", "inputSchema": {"type": "object"}}
]
}
})));
let client = McpClient::new(Box::new(transport), ClientConfig::default());
client.connect().await.unwrap();
let tools1 = client.list_tools().await.unwrap();
assert_eq!(tools1.len(), 1);
assert_eq!(tools1[0].name, "tool_a");
let tools2 = client.list_tools().await.unwrap();
assert_eq!(tools2.len(), 1);
assert_eq!(tools2[0].name, "tool_a");
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_tools_list_changed_notification_invalidates_cache() {
let (mock, _notifications) = MockTransport::new();
let event_sender = mock.event_sender();
let client = McpClientBuilder::new()
.with_transport(Box::new(mock))
.build();
client.connect().await.unwrap();
{
let mut cache = client.cached_tools.write().await;
*cache = Some(vec![]);
}
assert!(client.cached_tools.read().await.is_some());
event_sender
.send(ServerEvent::Notification(json!({
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed"
})))
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(
client.cached_tools.read().await.is_none(),
"Tool cache should be invalidated after notifications/tools/list_changed"
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_resources_list_changed_notification_invalidates_cache() {
let (mock, _notifications) = MockTransport::new();
let event_sender = mock.event_sender();
let client = McpClientBuilder::new()
.with_transport(Box::new(mock))
.build();
client.connect().await.unwrap();
{
let mut cache = client.cached_resources.write().await;
*cache = Some(vec![]);
}
assert!(client.cached_resources.read().await.is_some());
event_sender
.send(ServerEvent::Notification(json!({
"jsonrpc": "2.0",
"method": "notifications/resources/list_changed"
})))
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(
client.cached_resources.read().await.is_none(),
"Resource cache should be invalidated after notifications/resources/list_changed"
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_prompts_list_changed_notification_invalidates_cache() {
let (mock, _notifications) = MockTransport::new();
let event_sender = mock.event_sender();
let client = McpClientBuilder::new()
.with_transport(Box::new(mock))
.build();
client.connect().await.unwrap();
{
let mut cache = client.cached_prompts.write().await;
*cache = Some(vec![]);
}
assert!(client.cached_prompts.read().await.is_some());
event_sender
.send(ServerEvent::Notification(json!({
"jsonrpc": "2.0",
"method": "notifications/prompts/list_changed"
})))
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(
client.cached_prompts.read().await.is_none(),
"Prompt cache should be invalidated after notifications/prompts/list_changed"
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_user_notification_callback_fires() {
let (mock, _notifications) = MockTransport::new();
let event_sender = mock.event_sender();
let received_methods = Arc::new(parking_lot::Mutex::new(Vec::<String>::new()));
let received_methods_clone = Arc::clone(&received_methods);
let client = McpClientBuilder::new()
.with_transport(Box::new(mock))
.on_notification(move |method, _params| {
received_methods_clone.lock().push(method.to_string());
})
.build();
client.connect().await.unwrap();
event_sender
.send(ServerEvent::Notification(json!({
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed"
})))
.unwrap();
event_sender
.send(ServerEvent::Notification(json!({
"jsonrpc": "2.0",
"method": "notifications/custom/event",
"params": {"key": "value"}
})))
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let methods = received_methods.lock();
assert!(
methods.contains(&"notifications/tools/list_changed".to_string()),
"User callback should receive tools/list_changed, got: {:?}",
*methods
);
assert!(
methods.contains(&"notifications/custom/event".to_string()),
"User callback should receive custom notifications, got: {:?}",
*methods
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_refresh_tools_bypasses_cache() {
let mut transport = StatefulMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-1"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_1",
"result": {
"tools": [
{"name": "tool_a", "description": "Tool A", "inputSchema": {"type": "object"}}
]
}
})));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_2",
"result": {
"tools": [
{"name": "tool_a", "description": "Tool A", "inputSchema": {"type": "object"}},
{"name": "tool_b", "description": "Tool B", "inputSchema": {"type": "object"}}
]
}
})));
let client = McpClient::new(Box::new(transport), ClientConfig::default());
client.connect().await.unwrap();
let tools1 = client.list_tools().await.unwrap();
assert_eq!(tools1.len(), 1);
let tools2 = client.refresh_tools().await.unwrap();
assert_eq!(tools2.len(), 2);
let tools3 = client.list_tools().await.unwrap();
assert_eq!(tools3.len(), 2);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
struct ReconnectableMockTransport {
init_responses: Arc<std::sync::Mutex<VecDeque<McpClientResult<TransportResponse>>>>,
request_responses: Arc<std::sync::Mutex<VecDeque<McpClientResult<Value>>>>,
set_session_ids: Arc<std::sync::Mutex<Vec<String>>>,
clear_count: Arc<AtomicU32>,
connect_count: Arc<AtomicU32>,
disconnect_count: Arc<AtomicU32>,
connected: AtomicBool,
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
impl ReconnectableMockTransport {
fn new() -> Self {
Self {
init_responses: Arc::new(std::sync::Mutex::new(VecDeque::new())),
request_responses: Arc::new(std::sync::Mutex::new(VecDeque::new())),
set_session_ids: Arc::new(std::sync::Mutex::new(Vec::new())),
clear_count: Arc::new(AtomicU32::new(0)),
connect_count: Arc::new(AtomicU32::new(0)),
disconnect_count: Arc::new(AtomicU32::new(0)),
connected: AtomicBool::new(false),
}
}
fn push_init_response(&mut self, resp: McpClientResult<TransportResponse>) {
self.init_responses.lock().unwrap().push_back(resp);
}
fn push_request_response(&mut self, resp: McpClientResult<Value>) {
self.request_responses.lock().unwrap().push_back(resp);
}
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[async_trait]
impl crate::transport::Transport for ReconnectableMockTransport {
fn transport_type(&self) -> TransportType {
TransportType::Http
}
fn capabilities(&self) -> TransportCapabilities {
TransportCapabilities {
streaming: false,
bidirectional: false,
server_events: false, max_message_size: None,
persistent: false,
}
}
async fn connect(&self) -> McpClientResult<()> {
self.connect_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.connected.store(true, AtomicOrdering::SeqCst);
Ok(())
}
async fn disconnect(&self) -> McpClientResult<()> {
self.disconnect_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.connected.store(false, AtomicOrdering::SeqCst);
Ok(())
}
fn is_connected(&self) -> bool {
self.connected.load(AtomicOrdering::SeqCst)
}
async fn send_request(&self, _request: Value) -> McpClientResult<Value> {
self.request_responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| {
Err(McpClientError::generic(
"ReconnectableMockTransport: no more request responses queued",
))
})
}
async fn send_request_with_headers(
&self,
request: Value,
) -> McpClientResult<TransportResponse> {
if request.get("method").and_then(|m| m.as_str()) == Some("server/discover") {
return Ok(TransportResponse::new(
json!({"jsonrpc": "2.0", "id": "req_0", "error": {"code": -32601, "message": "Method not found"}}),
HashMap::new(),
));
}
self.init_responses
.lock()
.unwrap()
.pop_front()
.unwrap_or_else(|| {
Err(McpClientError::generic(
"ReconnectableMockTransport: no more init responses queued",
))
})
}
async fn send_notification(&self, _notification: Value) -> McpClientResult<()> {
Ok(())
}
async fn send_delete(&self, _session_id: &str) -> McpClientResult<()> {
Ok(())
}
fn set_session_id(&self, session_id: String) {
self.set_session_ids.lock().unwrap().push(session_id);
}
fn clear_session_id(&self) {
self.clear_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
async fn start_event_listener(&self) -> McpClientResult<EventReceiver> {
Err(McpClientError::generic("No event listener"))
}
fn connection_info(&self) -> ConnectionInfo {
ConnectionInfo {
transport_type: TransportType::Http,
endpoint: "reconnectable-mock://test".to_string(),
connected: self.is_connected(),
capabilities: self.capabilities(),
metadata: Value::Null,
}
}
fn statistics(&self) -> TransportStatistics {
TransportStatistics::default()
}
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_session_not_initialized_triggers_reconnect_and_retry() {
let mut transport = ReconnectableMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-AAA"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_1",
"error": {
"code": -32031,
"message": "Session error: Session not initialized - client must send notifications/initialized first (strict lifecycle mode)"
}
})));
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-BBB"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_2",
"result": {
"content": [{"type": "text", "text": "42"}],
"isError": false
}
})));
let connect_count = transport.connect_count.clone();
let disconnect_count = transport.disconnect_count.clone();
let set_ids = transport.set_session_ids.clone();
let client = McpClient::new(Box::new(transport), fast_retry_config(3));
client.connect().await.unwrap();
let result = client.call_tool("add", json!({"a": 1, "b": 2})).await;
assert!(
result.is_ok(),
"call_tool should succeed after -32031 reconnect: {:?}",
result.err()
);
let call_result = result.unwrap();
assert!(!call_result.is_error.unwrap_or(true));
assert_eq!(
connect_count.load(Ordering::SeqCst),
2,
"Should have connected twice (initial + reconnect)"
);
assert_eq!(
disconnect_count.load(Ordering::SeqCst),
1,
"Should have disconnected once during recovery"
);
let ids = set_ids.lock().unwrap();
assert!(
ids.contains(&"session-BBB".to_string()),
"New session ID 'session-BBB' should be set after reconnect, got: {:?}",
*ids
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_session_not_initialized_retry_fails_returns_retry_error() {
let mut transport = ReconnectableMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-AAA"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_1",
"error": {
"code": -32031,
"message": "Session error: Session not initialized"
}
})));
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-BBB"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_2",
"error": {
"code": -32031,
"message": "Session error: Session not initialized (still)"
}
})));
let client = McpClient::new(Box::new(transport), fast_retry_config(3));
client.connect().await.unwrap();
let result = client.call_tool("add", json!({})).await;
assert!(result.is_err(), "Should fail after retry also fails");
let err = result.unwrap_err();
assert_eq!(
err.error_code(),
Some(-32031),
"Should return the retry's error"
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_session_not_initialized_reconnect_fails_returns_original_error() {
let mut transport = ReconnectableMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-AAA"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_1",
"error": {
"code": -32031,
"message": "Session error: Session not initialized"
}
})));
let client = McpClient::new(Box::new(transport), fast_retry_config(3));
client.connect().await.unwrap();
let result = client.call_tool("add", json!({})).await;
assert!(result.is_err(), "Should fail when reconnect fails");
let err = result.unwrap_err();
assert!(
err.is_session_not_initialized(),
"Should return original -32031 error when reconnect fails, got: {}",
err
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_session_not_initialized_detected_by_message() {
let mut transport = ReconnectableMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-AAA"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_1",
"error": {
"code": -32000,
"message": "Session not initialized - client must send notifications/initialized first"
}
})));
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-BBB"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_2",
"result": { "tools": [] }
})));
let client = McpClient::new(Box::new(transport), fast_retry_config(3));
client.connect().await.unwrap();
let result = client.list_tools().await;
assert!(
result.is_ok(),
"Message-based detection should trigger reconnect: {:?}",
result.err()
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_non_session_error_does_not_trigger_reconnect() {
let mut transport = ReconnectableMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-AAA"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_1",
"error": {
"code": -32602,
"message": "Invalid params: missing 'name'"
}
})));
let connect_count = transport.connect_count.clone();
let client = McpClient::new(Box::new(transport), fast_retry_config(1));
client.connect().await.unwrap();
let result = client.call_tool("add", json!({})).await;
assert!(result.is_err());
assert_eq!(
connect_count.load(Ordering::SeqCst),
1,
"Non-session errors must not trigger reconnect"
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_call_tool_with_task_also_retries_on_session_error() {
let mut transport = ReconnectableMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-AAA"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_1",
"error": {
"code": -32031,
"message": "Session error: Session not initialized"
}
})));
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-BBB"),
"2025-11-25",
)));
transport.push_request_response(Ok(json!({
"jsonrpc": "2.0",
"id": "req_2",
"result": {
"content": [{"type": "text", "text": "done"}],
"isError": false
}
})));
let client = McpClient::new(Box::new(transport), fast_retry_config(3));
client.connect().await.unwrap();
let result = client
.call_tool_with_task("slow_add", json!({"a": 1}), None)
.await;
assert!(
result.is_ok(),
"call_tool_with_task should recover from -32031: {:?}",
result.err()
);
}
#[cfg(any(feature = "client-bilingual", feature = "client-2025-11-25-only"))]
#[tokio::test]
async fn test_invalidate_caches_clears_all() {
let mut transport = StatefulMockTransport::new();
transport.push_init_response(Ok(StatefulMockTransport::make_init_response(
Some("session-1"),
"2025-11-25",
)));
let client = McpClient::new(Box::new(transport), ClientConfig::default());
client.connect().await.unwrap();
*client.cached_tools.write().await = Some(vec![]);
*client.cached_resources.write().await = Some(vec![]);
*client.cached_prompts.write().await = Some(vec![]);
client.invalidate_caches().await;
assert!(client.cached_tools.read().await.is_none());
assert!(client.cached_resources.read().await.is_none());
assert!(client.cached_prompts.read().await.is_none());
}
fn fresh_client_for_envelope_tests() -> McpClient {
let (mock, _notifications) = MockTransport::new();
McpClient::new(Box::new(mock), ClientConfig::default())
}
#[test]
fn test_value_to_request_params_null_yields_none() {
assert!(value_to_request_params(Value::Null).is_none());
}
#[test]
fn test_value_to_request_params_empty_object_yields_some_empty() {
let result = value_to_request_params(json!({}));
match result {
Some(RequestParams::Object(map)) => assert!(map.is_empty()),
other => panic!("expected Some(Object(empty)), got {:?}", other),
}
}
#[test]
fn test_value_to_request_params_object_preserves_entries() {
let result = value_to_request_params(json!({"name": "x", "n": 42}));
match result {
Some(RequestParams::Object(map)) => {
assert_eq!(map.get("name"), Some(&json!("x")));
assert_eq!(map.get("n"), Some(&json!(42)));
}
other => panic!("expected Some(Object), got {:?}", other),
}
}
#[test]
fn test_value_to_request_params_array() {
let result = value_to_request_params(json!([1, 2, 3]));
match result {
Some(RequestParams::Array(arr)) => {
assert_eq!(arr, vec![json!(1), json!(2), json!(3)]);
}
other => panic!("expected Some(Array), got {:?}", other),
}
}
#[test]
#[should_panic(expected = "MCP client requests use object or null params")]
fn test_value_to_request_params_scalar_panics() {
let _ = value_to_request_params(json!("just-a-string"));
}
#[tokio::test]
async fn test_build_request_envelope_shape() {
let client = fresh_client_for_envelope_tests();
let envelope = client.build_request("ping", json!({}));
assert_eq!(envelope["jsonrpc"], json!("2.0"));
assert_eq!(envelope["method"], json!("ping"));
assert_eq!(envelope["id"], json!("req_0"));
assert_eq!(
envelope["params"],
json!({}),
"empty Object params MUST serialize as `\"params\":{{}}` (not omitted) for wire-byte compat"
);
}
#[tokio::test]
async fn test_build_request_with_object_params() {
let client = fresh_client_for_envelope_tests();
let envelope = client.build_request(
"tools/call",
json!({"name": "calc", "arguments": {"a": 1, "b": 2}}),
);
assert_eq!(envelope["method"], json!("tools/call"));
assert_eq!(envelope["params"]["name"], json!("calc"));
assert_eq!(envelope["params"]["arguments"]["a"], json!(1));
assert_eq!(envelope["params"]["arguments"]["b"], json!(2));
}
#[tokio::test]
async fn test_build_request_preserves_nested_array_values_in_arguments() {
let client = fresh_client_for_envelope_tests();
let envelope = client.build_request(
"tools/call",
json!({
"name": "compute_stats",
"arguments": {
"values": [1.0, 2.0, 3.0, 4.5],
"tags": ["alpha", "beta"],
"matrix": [[1, 2], [3, 4]],
}
}),
);
assert_eq!(envelope["method"], json!("tools/call"));
assert_eq!(envelope["params"]["name"], json!("compute_stats"));
assert_eq!(
envelope["params"]["arguments"]["values"],
json!([1.0, 2.0, 3.0, 4.5]),
"numeric array argument must survive intact"
);
assert_eq!(
envelope["params"]["arguments"]["tags"],
json!(["alpha", "beta"]),
"string array argument must survive intact"
);
assert_eq!(
envelope["params"]["arguments"]["matrix"],
json!([[1, 2], [3, 4]]),
"nested-array argument must survive intact"
);
}
#[tokio::test]
async fn test_build_request_id_increments_per_call() {
let client = fresh_client_for_envelope_tests();
let a = client.build_request("ping", json!({}));
let b = client.build_request("ping", json!({}));
let c = client.build_request("ping", json!({}));
assert_eq!(a["id"], json!("req_0"));
assert_eq!(b["id"], json!("req_1"));
assert_eq!(c["id"], json!("req_2"));
}
#[tokio::test]
async fn test_build_request_null_params_omits_field() {
let client = fresh_client_for_envelope_tests();
let envelope = client.build_request("ping", Value::Null);
assert_eq!(envelope["jsonrpc"], json!("2.0"));
assert_eq!(envelope["method"], json!("ping"));
assert!(
envelope.get("params").is_none(),
"Value::Null params MUST omit the `params` field (skip_serializing_if=Option::is_none)"
);
}
#[test]
fn test_build_notification_envelope_shape() {
let envelope = McpClient::build_notification("notifications/initialized", json!({}));
assert_eq!(envelope["jsonrpc"], json!("2.0"));
assert_eq!(envelope["method"], json!("notifications/initialized"));
assert_eq!(envelope["params"], json!({}));
assert!(
envelope.get("id").is_none(),
"notifications MUST NOT include an `id` field per JSON-RPC 2.0 §4.1"
);
}
#[test]
fn test_build_notification_with_object_params() {
let envelope = McpClient::build_notification(
"notifications/progress",
json!({"progressToken": "tok-1", "progress": 0.5}),
);
assert!(envelope.get("id").is_none());
assert_eq!(envelope["method"], json!("notifications/progress"));
assert_eq!(envelope["params"]["progressToken"], json!("tok-1"));
assert_eq!(envelope["params"]["progress"], json!(0.5));
}
#[test]
fn test_build_notification_null_params_omits_field() {
let envelope = McpClient::build_notification("notifications/cancelled", Value::Null);
assert_eq!(envelope["jsonrpc"], json!("2.0"));
assert_eq!(envelope["method"], json!("notifications/cancelled"));
assert!(envelope.get("params").is_none());
assert!(envelope.get("id").is_none());
}
#[tokio::test]
async fn test_build_request_matches_legacy_handrolled_form() {
let client = fresh_client_for_envelope_tests();
let typed = client.build_request("tools/list", json!({"cursor": "abc"}));
let legacy = json!({
"jsonrpc": "2.0",
"method": "tools/list",
"id": "req_0",
"params": {"cursor": "abc"},
});
assert_eq!(
typed, legacy,
"build_request envelope must be semantically equivalent to the prior hand-rolled form"
);
}
#[tokio::test]
async fn test_concurrent_build_request_ids_are_unique() {
let (mock, _notifications) = MockTransport::new();
let client = Arc::new(McpClient::new(Box::new(mock), ClientConfig::default()));
let mut handles = Vec::new();
for _ in 0..64 {
let client = Arc::clone(&client);
handles.push(tokio::spawn(async move {
client.build_request("ping", json!({}))["id"]
.as_str()
.unwrap()
.to_string()
}));
}
let mut ids = std::collections::HashSet::new();
for handle in handles {
let id = handle.await.unwrap();
assert!(
ids.insert(id.clone()),
"duplicate request id observed: {id}"
);
}
assert_eq!(
ids.len(),
64,
"every concurrent request must get a distinct id"
);
}
}