//! MCP client implementation for FastMCP.
//!
//! This crate provides the client-side implementation:
//! - Client builder pattern
//! - Tool invocation
//! - Resource reading
//! - Prompt fetching
//!
//! MCP 2026-07-28 support is under implementation and remains unverified. The
//! public stdio constructor starts with a modern `server/discover` probe and
//! opens a fresh exact-2024 child only for a correlated `MethodNotFound`
//! refusal or Unix-observable clean first-probe timeout; this source inventory
//! is not aggregate conformance or release evidence.
//!
//! # Example
//!
//! ```ignore
//! use fastmcp_rust::Client;
//!
//! let mut client = Client::stdio("uvx", &["my-mcp-server"])?;
//!
//! // List tools
//! let tools = client.list_tools()?;
//!
//! // Call a no-argument tool
//! let result = client.call_tool("status", Default::default())?;
//! ```
//!
//! # Role in the System
//!
//! `fastmcp-client` is the **companion client** to `fastmcp-server`. It uses
//! the same protocol models and transport layer to:
//! - Spawn MCP servers as subprocesses (stdio)
//! - Initialize sessions and negotiate capabilities
//! - Call tools, read resources, and fetch prompts
//!
//! If you are embedding FastMCP into a larger application (e.g. testing,
//! orchestration, or local agent tooling), this is the crate that drives the
//! client side of the protocol.
#![forbid(unsafe_code)]
#![allow(dead_code)]
mod builder;
mod cache;
mod execution;
pub mod http_auth;
#[cfg_attr(
not(feature = "legacy-2024-11-05"),
doc = r#"
Feature-off downstream consumers cannot name or open the exact-2024 SSE client.
```compile_fail
use fastmcp_client::LegacySseHttpClient;
let _ = LegacySseHttpClient::connect;
```
```compile_fail
use fastmcp_client::http_executor::LegacySseHttpClient;
let _ = LegacySseHttpClient::connect;
```
```compile_fail
use fastmcp_client::http_executor::{ClientHttpConnection, ModernHttpConnectOutcome};
let _ = ClientHttpConnection::LegacySse;
let _ = ModernHttpConnectOutcome::LegacySse;
```
```compile_fail
use fastmcp_client::HttpClient;
let _ = HttpClient::take_legacy_notification;
```
```compile_fail
use fastmcp_client::Client;
let _ = Client::sse;
```
"#
)]
pub mod http_executor;
#[cfg(feature = "apps")]
pub mod mcp_apps;
pub mod mcp_config;
mod negotiation;
mod session;
pub mod sse;
pub use builder::ClientBuilder;
pub use cache::{
CachePartitionKey, DEFAULT_FINAL_CACHE_CAPACITY, DEFAULT_FINAL_CACHE_MAX_BYTES,
FinalCacheGeneration, FinalCacheInsert, FinalCacheKey, FinalCacheLookup, FinalCacheMiss,
FinalCacheResultSet, FinalCacheStats, FinalResultCache, MAX_FINAL_CACHE_CAPACITY,
MAX_FINAL_CACHE_MAX_BYTES,
};
pub use execution::{
CancellationRequested, ExecutionTerminalReason, ExecutionTerminalRecord,
ExecutionTerminalState, FinalCacheTtlDiagnostic, OpaquePagination, PaginationBounds,
PendingRequestRecord, Request, RequestExecution, RequestExecutor, ReverseRequest,
ReverseRequestCancellation, clt_01_a_manifest_digest, clt_01_b_manifest_digest,
};
pub use fastmcp_core::CanonicalHttpUrl;
pub use fastmcp_protocol::common_types::LoggingLevel;
#[cfg(feature = "apps")]
pub use fastmcp_protocol::extensions::McpAppsClientSettings;
#[cfg(feature = "websocket-experimental")]
pub use fastmcp_transport::websocket::AsyncWsClientTransport;
// The public re-export is feature-gated, while the client keeps this type
// internally to reject configuration that names Apps when that feature is
// absent. Keep the private name available in feature-off builds.
#[cfg(not(feature = "apps"))]
use fastmcp_protocol::extensions::McpAppsClientSettings;
pub use fastmcp_protocol::protocol_policy::{
HttpEndpointBundle, HttpEndpointBundleError, HttpModernProbe, HttpProbeBody, ProtocolEra,
ProtocolPolicy, ProtocolVersion,
};
#[cfg(feature = "tasks")]
pub use fastmcp_protocol::tasks_extension::{
CancelTaskResult as FinalCancelTaskResult, GetTaskResult as FinalGetTaskResult,
Task as FinalTask, TaskId as FinalTaskId, TaskInputResponses as FinalTaskInputResponses,
TaskStatusNotification as FinalTaskStatusNotification,
UpdateTaskResult as FinalUpdateTaskResult,
};
pub use fastmcp_protocol::{
CallToolResult, CompleteResult, CoreResult, CreateMessageParams, CreateMessageResult,
ElicitRequestParams, ElicitResult, FinalCallToolResult,
FinalCompletionArgument as CompletionArgument, FinalCompletionContext as CompletionContext,
FinalCompletionReference as CompletionReference, FinalCoreResult, FinalCreateMessageParams,
FinalCreateMessageResult, FinalEmbeddedRootsListParams, FinalEmbeddedRootsListResult,
FinalGetPromptResult, FinalReadResourceResult, FinalSubscriptionsListenResult, GetPromptResult,
InputRequiredResult, LegacyCoreRequest, LegacyCoreResult, ListRootsParams, ListRootsResult,
ReadResourceResult, SubscriptionFilter,
};
use fastmcp_protocol::{
ElicitRequestFormParams, ElicitRequestUrlParams, FinalEmbeddedCreateMessageParams,
FinalEmbeddedElicitationParams, FinalEmbeddedInputRequest, exact_json_to_serde,
};
pub use http_executor::{
ClientHttpConnection, ClientHttpConnectionError, ClientHttpResponse, ModernHttpClient,
ModernHttpClientError, ModernHttpResponseStream, ModernHttpSubscriptionListenCollector,
ModernHttpSubscriptionListenEvent, ModernHttpSubscriptionListener,
};
#[cfg(feature = "legacy-2024-11-05")]
pub use http_executor::{
LegacyHttpRequest, LegacyHttpRequestCommit, LegacySseHttpClient, LegacySseHttpClientError,
};
/// Opt-in module for the caller-upgraded async WebSocket transport.
///
/// Enable `websocket-experimental` to use this module or import
/// [`AsyncWsClientTransport`] directly from this crate.
#[cfg(feature = "websocket-experimental")]
pub mod websocket_experimental {
pub use fastmcp_transport::websocket::AsyncWsClientTransport;
}
#[cfg(feature = "apps")]
pub use mcp_apps::{
McpAppsBridgeTransport, McpAppsClientWirePolicy, McpAppsHost, McpAppsHostConfiguration,
McpAppsHostError, McpAppsHostPolicy, McpAppsHttpClientWirePolicy, McpAppsInMemoryHostTransport,
McpAppsInMemoryViewTransport, McpAppsInMemoryWireHostTransport,
McpAppsInMemoryWireViewTransport, McpAppsWireBridgeTransport, McpAppsWireHost,
McpAppsWireHostConfiguration, McpAppsWireHostPolicy, mcp_apps_in_memory_pair,
mcp_apps_in_memory_wire_pair,
};
pub use mcp_config::claude_desktop_config_path;
pub use negotiation::{
ClientHttpNegotiation, ClientHttpNegotiationDecision, ClientHttpNegotiationError,
ClientHttpNegotiationState,
};
pub(crate) use session::ClientExtensionRuntime;
pub use session::{ClientProtocolPlan, ClientProtocolPlanError, ClientSession};
use std::any::Any;
use std::cell::Cell;
use std::collections::{BTreeMap, VecDeque};
use std::future::Future;
#[cfg(any(target_os = "linux", all(test, unix)))]
use std::io::Read;
#[cfg(all(test, unix))]
use std::io::Write;
#[cfg(unix)]
use std::os::fd::OwnedFd;
#[cfg(unix)]
use std::os::unix::net::UnixStream;
#[cfg(unix)]
use std::os::unix::process::CommandExt as _;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::process::{Child, ChildStdin, ChildStdout, Command, ExitStatus, Stdio};
#[cfg(test)]
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
#[cfg(all(test, unix, any(feature = "legacy-2024-11-05", feature = "tasks")))]
use std::sync::mpsc;
use std::sync::{Arc, Mutex, Once};
use std::time::{Duration, Instant};
use asupersync::{Cx, channel::oneshot};
#[cfg(feature = "websocket-experimental")]
use asupersync::{
io::{AsyncRead, AsyncWrite},
sync::{Mutex as AsyncMutex, OwnedMutexGuard},
};
use execution::{MrtrDriver, MrtrDriverLimits};
use fastmcp_core::{
McpContext, McpError, McpErrorCode, McpRequestCancellation, McpResult, Sha256Digest, block_on,
sha256_bounded,
};
use fastmcp_protocol::common_types::{
ContentBlock, EmbeddedResourceContents, JsonInteger, OpenMetadata, RawIcon,
};
#[cfg(feature = "tasks")]
use fastmcp_protocol::extensions::ExtensionLocalEnablement;
#[cfg(feature = "tasks")]
use fastmcp_protocol::extensions::{
OFFICIAL_TASKS_RESULT_DISCRIMINATOR, official_tasks_empty_settings,
register_official_tasks_extension,
};
#[cfg(feature = "websocket-experimental")]
use fastmcp_protocol::methods::Final2026EnvelopeKind;
use fastmcp_protocol::methods::{
Final2026Peer, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_MESSAGE, NOTIFICATIONS_PROGRESS,
NOTIFICATIONS_PROMPTS_LIST_CHANGED, NOTIFICATIONS_RESOURCES_LIST_CHANGED,
NOTIFICATIONS_RESOURCES_UPDATED, NOTIFICATIONS_ROOTS_LIST_CHANGED,
NOTIFICATIONS_TOOLS_LIST_CHANGED, final_2026_07_28_method,
};
use fastmcp_protocol::protocol_policy::MODERN_PROTOCOL_VERSION;
#[cfg(feature = "tasks")]
use fastmcp_protocol::task_subscription_ids;
#[cfg(feature = "tasks")]
use fastmcp_protocol::tasks_extension::{
CancelTaskParams as FinalCancelTaskParams, GetTaskParams as FinalGetTaskParams, TASK_CANCEL,
TASK_GET, TASK_STATUS_NOTIFICATION, TASK_SUBSCRIPTION_IDS_KEY, TASK_UPDATE, TaskInputLedger,
TaskRequestMeta, UpdateTaskParams as FinalUpdateTaskParams,
};
use fastmcp_protocol::{
CallToolParams, CancellationSender, CancellationWireMessage, CancelledParams,
ClientCapabilities, ClientInfo, CoreDispatchError, CoreRequest, CorrelationKey,
ElicitationCapability, FINAL_CLIENT_CAPABILITIES_META_KEY, FINAL_CLIENT_INFO_META_KEY,
FINAL_LOG_LEVEL_META_KEY, FINAL_SUBSCRIPTION_ID_META_KEY, FinalCancelledNotificationParams,
FinalCoreRequest, FinalLogMessageParams, FinalProgressNotificationParams, FinalRequestMeta,
FinalSubscriptionsAcknowledgedNotificationParams, GetPromptParams, InitializeParams,
InitializeResult, JSONRPC_VERSION, JsonRpcError, JsonRpcMessage, JsonRpcRequest,
JsonRpcResponse, LegacyContent, LegacyPromptMessage, LegacyResourceContent, ListPromptsParams,
ListResourceTemplatesParams, ListResourcesParams, ListToolsParams, LogLevel, LogMessageParams,
PROTOCOL_VERSION, ProgressMarker, Prompt, PromptArgument, ReadResourceParams, RequestId,
RequestMeta, Resource, ResourceTemplate, RootsCapability, SamplingCapability,
ServerCapabilities, ServerInfo, ServerNotification, SetLogLevelParams, SubscribeResourceParams,
Tool, ToolAnnotations, UnsubscribeResourceParams, decode_strict_jsonrpc_response,
};
#[cfg(feature = "tasks")]
use fastmcp_protocol::{
ClientExtensionDiscovery, ExtensionDescriptorRegistry, ExtensionDirection, ExtensionSettings,
ServerExtensionDiscovery,
};
use fastmcp_protocol::{SERVER_DISCOVER_METHOD, ServerDiscoverRequest, ServerDiscoverResult};
use crate::session::mcp_apps_activation_receipt;
/// Callback for receiving progress notifications during tool execution.
///
/// The callback receives the progress value, optional total, and optional message.
pub type ProgressCallback<'a> = &'a mut dyn FnMut(f64, Option<f64>, Option<&str>);
/// Erased, cancellation-aware result future returned by a reverse handler.
pub type ReverseRequestFuture<'cx, T> = Pin<Box<dyn Future<Output = McpResult<T>> + Send + 'cx>>;
/// Async handler for a server-initiated `sampling/createMessage` request.
///
/// The connection supplies a child [`Cx`] that is aborted when the matching
/// exact-2024 cancellation notification arrives. Implementations must use that
/// context for every awaitable effect.
pub type SamplingRequestHandler = Arc<
dyn for<'cx> Fn(
&'cx Cx,
ReverseRequestCancellation,
CreateMessageParams,
) -> ReverseRequestFuture<'cx, CreateMessageResult>
+ Send
+ Sync,
>;
/// Async handler for a server-initiated `roots/list` request.
pub type RootsRequestHandler = Arc<
dyn for<'cx> Fn(
&'cx Cx,
ReverseRequestCancellation,
ListRootsParams,
) -> ReverseRequestFuture<'cx, ListRootsResult>
+ Send
+ Sync,
>;
/// Async handler for a modern `sampling/createMessage` reverse request.
pub type ModernSamplingRequestHandler = Arc<
dyn for<'cx> Fn(
&'cx Cx,
ReverseRequestCancellation,
FinalCreateMessageParams,
) -> ReverseRequestFuture<'cx, FinalCreateMessageResult>
+ Send
+ Sync,
>;
/// Async handler for a modern `roots/list` reverse request.
pub type ModernRootsRequestHandler = Arc<
dyn for<'cx> Fn(
&'cx Cx,
ReverseRequestCancellation,
FinalEmbeddedRootsListParams,
) -> ReverseRequestFuture<'cx, FinalEmbeddedRootsListResult>
+ Send
+ Sync,
>;
/// Async handler for a modern `elicitation/create` reverse request.
pub type ModernElicitationRequestHandler = Arc<
dyn for<'cx> Fn(
&'cx Cx,
ReverseRequestCancellation,
ElicitRequestParams,
) -> ReverseRequestFuture<'cx, ElicitResult>
+ Send
+ Sync,
>;
/// Configurable handlers for reverse requests received from a live MCP server.
#[derive(Clone, Default)]
pub struct ReverseRequestHandlers {
sampling_create_message: Option<SamplingRequestHandler>,
roots_list: Option<RootsRequestHandler>,
modern_sampling_create_message: Option<ModernSamplingRequestHandler>,
modern_roots_list: Option<ModernRootsRequestHandler>,
modern_elicitation_create: Option<ModernElicitationRequestHandler>,
}
impl ReverseRequestHandlers {
/// Creates an empty handler set. Unconfigured methods receive `MethodNotFound`.
#[must_use]
pub const fn new() -> Self {
Self {
sampling_create_message: None,
roots_list: None,
modern_sampling_create_message: None,
modern_roots_list: None,
modern_elicitation_create: None,
}
}
/// Configures handling for `sampling/createMessage`.
#[must_use]
pub fn with_sampling_create_message<F>(mut self, handler: F) -> Self
where
F: for<'cx> Fn(
&'cx Cx,
ReverseRequestCancellation,
CreateMessageParams,
) -> ReverseRequestFuture<'cx, CreateMessageResult>
+ Send
+ Sync
+ 'static,
{
self.sampling_create_message = Some(Arc::new(handler));
self
}
/// Configures handling for `roots/list`.
#[must_use]
pub fn with_roots_list<F>(mut self, handler: F) -> Self
where
F: for<'cx> Fn(
&'cx Cx,
ReverseRequestCancellation,
ListRootsParams,
) -> ReverseRequestFuture<'cx, ListRootsResult>
+ Send
+ Sync
+ 'static,
{
self.roots_list = Some(Arc::new(handler));
self
}
/// Configures handling for modern `sampling/createMessage`.
#[must_use]
pub fn with_modern_sampling_create_message<F>(mut self, handler: F) -> Self
where
F: for<'cx> Fn(
&'cx Cx,
ReverseRequestCancellation,
FinalCreateMessageParams,
) -> ReverseRequestFuture<'cx, FinalCreateMessageResult>
+ Send
+ Sync
+ 'static,
{
self.modern_sampling_create_message = Some(Arc::new(handler));
self
}
/// Configures handling for modern `roots/list`.
#[must_use]
pub fn with_modern_roots_list<F>(mut self, handler: F) -> Self
where
F: for<'cx> Fn(
&'cx Cx,
ReverseRequestCancellation,
FinalEmbeddedRootsListParams,
) -> ReverseRequestFuture<'cx, FinalEmbeddedRootsListResult>
+ Send
+ Sync
+ 'static,
{
self.modern_roots_list = Some(Arc::new(handler));
self
}
/// Configures handling for modern `elicitation/create`.
#[must_use]
pub fn with_modern_elicitation_create<F>(mut self, handler: F) -> Self
where
F: for<'cx> Fn(
&'cx Cx,
ReverseRequestCancellation,
ElicitRequestParams,
) -> ReverseRequestFuture<'cx, ElicitResult>
+ Send
+ Sync
+ 'static,
{
self.modern_elicitation_create = Some(Arc::new(handler));
self
}
pub(crate) fn is_empty(&self) -> bool {
self.sampling_create_message.is_none() && self.roots_list.is_none()
}
pub(crate) fn has_modern_handlers(&self) -> bool {
self.modern_sampling_create_message.is_some()
|| self.modern_roots_list.is_some()
|| self.modern_elicitation_create.is_some()
}
/// Fulfills one peer `input_required` result by invoking the matching
/// installed modern reverse handlers locally.
///
/// This is the modern MRTR path: the server asked for sampling, roots, or
/// elicitation inside `inputRequests`, and the client supplies
/// `inputResponses` on the next `tools/call` without sending reverse
/// JSON-RPC.
pub(crate) fn respond_to_input_required(
&self,
cx: &Cx,
input_required: &InputRequiredResult,
) -> McpResult<MrtrInputResponses> {
let Some(input_requests) = input_required.input_requests() else {
return Ok(MrtrInputResponses::new());
};
let mut responses = MrtrInputResponses::new();
for member in input_requests.members() {
let wire = exact_json_to_serde(&member.value).map_err(|error| {
McpError::invalid_params(format!(
"MRTR input request {} is not valid JSON: {error}",
member.name
))
})?;
let request: FinalEmbeddedInputRequest =
serde_json::from_value(wire).map_err(|error| {
McpError::invalid_params(format!(
"MRTR input request {} is not a final embedded descriptor: {error}",
member.name
))
})?;
let value = self.invoke_embedded_input_request(cx, &member.name, request)?;
responses.insert(member.name.clone(), value);
}
Ok(responses)
}
/// Async counterpart of [`Self::respond_to_input_required`] for callers
/// already on an asupersync runtime. Nested `block_on` would cancel.
pub(crate) async fn respond_to_input_required_async(
&self,
cx: &Cx,
input_required: &InputRequiredResult,
) -> McpResult<MrtrInputResponses> {
let Some(input_requests) = input_required.input_requests() else {
return Ok(MrtrInputResponses::new());
};
let mut responses = MrtrInputResponses::new();
for member in input_requests.members() {
let wire = exact_json_to_serde(&member.value).map_err(|error| {
McpError::invalid_params(format!(
"MRTR input request {} is not valid JSON: {error}",
member.name
))
})?;
let request: FinalEmbeddedInputRequest =
serde_json::from_value(wire).map_err(|error| {
McpError::invalid_params(format!(
"MRTR input request {} is not a final embedded descriptor: {error}",
member.name
))
})?;
let value = self
.invoke_embedded_input_request_async(cx, &member.name, request)
.await?;
responses.insert(member.name.clone(), value);
}
Ok(responses)
}
fn invoke_embedded_input_request(
&self,
cx: &Cx,
input_key: &str,
request: FinalEmbeddedInputRequest,
) -> McpResult<serde_json::Value> {
match request {
FinalEmbeddedInputRequest::Sampling(params) => {
let Some(handler) = self.modern_sampling_create_message.as_ref() else {
return Err(McpError::invalid_params(format!(
"MRTR input request {input_key} requires an installed sampling/createMessage reverse handler"
)));
};
let result = invoke_locked_reverse_request_handler(
cx,
handler,
ReverseRequestCancellation::new(),
final_create_message_params_from_embedded(params),
)?;
serde_json::to_value(result).map_err(|error| {
McpError::internal_error(format!(
"MRTR sampling input response could not serialize: {error}"
))
})
}
FinalEmbeddedInputRequest::Roots(params) => {
let Some(handler) = self.modern_roots_list.as_ref() else {
return Err(McpError::invalid_params(format!(
"MRTR input request {input_key} requires an installed roots/list reverse handler"
)));
};
let result = invoke_locked_reverse_request_handler(
cx,
handler,
ReverseRequestCancellation::new(),
params,
)?;
serde_json::to_value(result).map_err(|error| {
McpError::internal_error(format!(
"MRTR roots input response could not serialize: {error}"
))
})
}
FinalEmbeddedInputRequest::Elicitation(params) => {
let Some(handler) = self.modern_elicitation_create.as_ref() else {
return Err(McpError::invalid_params(format!(
"MRTR input request {input_key} requires an installed elicitation/create reverse handler"
)));
};
let result = invoke_locked_reverse_request_handler(
cx,
handler,
ReverseRequestCancellation::new(),
elicit_request_params_from_embedded(input_key, params),
)?;
serde_json::to_value(result).map_err(|error| {
McpError::internal_error(format!(
"MRTR elicitation input response could not serialize: {error}"
))
})
}
}
}
async fn invoke_embedded_input_request_async(
&self,
cx: &Cx,
input_key: &str,
request: FinalEmbeddedInputRequest,
) -> McpResult<serde_json::Value> {
match request {
FinalEmbeddedInputRequest::Sampling(params) => {
let Some(handler) = self.modern_sampling_create_message.as_ref() else {
return Err(McpError::invalid_params(format!(
"MRTR input request {input_key} requires an installed sampling/createMessage reverse handler"
)));
};
let result = handler(
cx,
ReverseRequestCancellation::new(),
final_create_message_params_from_embedded(params),
)
.await?;
serde_json::to_value(result).map_err(|error| {
McpError::internal_error(format!(
"MRTR sampling input response could not serialize: {error}"
))
})
}
FinalEmbeddedInputRequest::Roots(params) => {
let Some(handler) = self.modern_roots_list.as_ref() else {
return Err(McpError::invalid_params(format!(
"MRTR input request {input_key} requires an installed roots/list reverse handler"
)));
};
let result = handler(cx, ReverseRequestCancellation::new(), params).await?;
serde_json::to_value(result).map_err(|error| {
McpError::internal_error(format!(
"MRTR roots input response could not serialize: {error}"
))
})
}
FinalEmbeddedInputRequest::Elicitation(params) => {
let Some(handler) = self.modern_elicitation_create.as_ref() else {
return Err(McpError::invalid_params(format!(
"MRTR input request {input_key} requires an installed elicitation/create reverse handler"
)));
};
let result = handler(
cx,
ReverseRequestCancellation::new(),
elicit_request_params_from_embedded(input_key, params),
)
.await?;
serde_json::to_value(result).map_err(|error| {
McpError::internal_error(format!(
"MRTR elicitation input response could not serialize: {error}"
))
})
}
}
}
/// Adds the exact 2024-11-05 client capabilities implied by these
/// callbacks. Roots-list-change remains disabled because registering a
/// roots handler does not authorize the client to originate change events.
pub(crate) fn derive_modern_capabilities(&self, capabilities: &mut ClientCapabilities) {
if self.modern_sampling_create_message.is_some() {
capabilities.sampling.get_or_insert(SamplingCapability {});
}
if self.modern_roots_list.is_some() {
capabilities.roots.get_or_insert(RootsCapability {
list_changed: false,
});
}
if self.modern_elicitation_create.is_some() {
capabilities
.elicitation
.get_or_insert(ElicitationCapability::both());
}
}
pub(crate) fn derive_legacy_capabilities(&self, capabilities: &mut ClientCapabilities) {
if self.sampling_create_message.is_some() {
capabilities.sampling.get_or_insert(SamplingCapability {});
}
if self.roots_list.is_some() {
capabilities.roots.get_or_insert(RootsCapability {
list_changed: false,
});
}
}
/// Ensures that an exact-2024 callback configuration and its advertised
/// capabilities describe precisely the same server-callable surface.
pub(crate) fn validate_legacy_capabilities(
&self,
capabilities: &ClientCapabilities,
) -> McpResult<()> {
if self.sampling_create_message.is_some() != capabilities.sampling.is_some() {
return Err(McpError::invalid_params(
"MCP 2024-11-05 sampling callback configuration must match the advertised sampling capability",
));
}
match (&self.roots_list, &capabilities.roots) {
// `roots/listChanged` authorizes a separate client-originated
// notification. It does not narrow the server's authority to
// request `roots/list`, so both values are valid when the
// callable roots handler is present.
(Some(_), Some(_)) => {}
(Some(_), None) | (None, Some(_)) => {
return Err(McpError::invalid_params(
"MCP 2024-11-05 roots callback configuration must match the advertised roots capability",
));
}
(None, None) => {}
}
if capabilities.elicitation.is_some() {
return Err(McpError::invalid_params(
"MCP 2024-11-05 does not define an elicitation client capability",
));
}
Ok(())
}
}
#[cfg(feature = "websocket-experimental")]
use fastmcp_transport::websocket::{AsyncWsClientRecvHalf, AsyncWsClientSendHalf};
use fastmcp_transport::{
ClientTransportRecvHalf, ReceivedTransportFrame, StdioRecvHalf, StdioSendHalf, StdioTransport,
Transport, TransportError, TransportRecvHalf, TransportSendHalf,
};
use crate::cache::{FinalCachePageLookup, final_cache_hints};
use crate::execution::{
decode_core_result_from_source, decode_core_result_with_cache_ttl_from_source,
};
/// Completion input that retains the complete 2026-07-28 request context.
///
/// A modern session sends this shape unchanged apart from client-owned request
/// metadata. A legacy session accepts only the lossless subset: no prompt
/// title and no completion context.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CompletionParams {
/// Prompt or resource-template target.
#[serde(rename = "ref")]
pub reference: CompletionReference,
/// Argument being completed.
pub argument: CompletionArgument,
/// Previously resolved prompt or resource-template variables.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<CompletionContext>,
}
impl CompletionParams {
fn into_legacy(self) -> McpResult<fastmcp_protocol::LegacyCompletionParams> {
if self.context.is_some() {
return Err(McpError::invalid_params(
"MCP 2024-11-05 completion cannot represent completion context",
));
}
let reference = match self.reference {
CompletionReference::Prompt { name } => {
fastmcp_protocol::LegacyCompletionReference::Prompt { name }
}
CompletionReference::PromptWithTitle { .. } => {
return Err(McpError::invalid_params(
"MCP 2024-11-05 completion cannot represent a prompt title",
));
}
CompletionReference::Resource { uri } => {
fastmcp_protocol::LegacyCompletionReference::Resource { uri }
}
};
Ok(fastmcp_protocol::LegacyCompletionParams {
reference,
argument: fastmcp_protocol::LegacyCompletionArgument {
name: self.argument.name,
value: self.argument.value,
},
meta: None,
})
}
}
/// The request-owned terminal record for a final subscription listener.
///
/// The acknowledgement binds the requested filter to the JSON-RPC request
/// ID. Only notifications admitted by that accepted filter are retained here;
/// unrelated final log and progress notifications remain on the ordinary
/// client path.
#[derive(Debug, Clone)]
pub struct SubscriptionListenCollector {
/// The JSON-RPC request identity bound to this subscription stream.
pub subscription_id: RequestId,
/// The exact subset of requested notification categories accepted by the server.
pub accepted_filter: SubscriptionFilter,
/// Typed request-owned notifications received before graceful termination.
pub notifications: Vec<ServerNotification>,
/// Typed Tasks events admitted by the acknowledged exact `taskIds` set.
#[cfg(feature = "tasks")]
pub task_notifications: Vec<FinalTaskStatusNotification>,
/// The final complete result that terminated the listener.
pub terminal: CompleteResult<FinalSubscriptionsListenResult>,
}
/// Exact modern `tools/call` outcome without projecting away result algebra.
#[derive(Debug, Clone)]
pub enum FinalToolCallOutcome {
/// The tool completed synchronously with final content.
Complete(CompleteResult<FinalCallToolResult>),
/// The tool created a durable Tasks-extension task.
#[cfg(feature = "tasks")]
Task(fastmcp_protocol::CreateTaskResult),
/// The tool requires client input before it can complete.
InputRequired(fastmcp_protocol::InputRequiredResult),
}
/// Responses supplied to one final model-request tool retry (MRTR).
///
/// Keys must exactly cover the input requests from the immediately preceding
/// `input_required` result. Values remain JSON because the peer owns the
/// individual embedded-request schemas.
pub type MrtrInputResponses = BTreeMap<String, serde_json::Value>;
fn final_create_message_params_from_embedded(
params: FinalEmbeddedCreateMessageParams,
) -> FinalCreateMessageParams {
FinalCreateMessageParams {
meta: fastmcp_protocol::common_types::OpenMetadata::default(),
messages: params.messages,
max_tokens: params.max_tokens,
system_prompt: params.system_prompt,
temperature: params.temperature,
stop_sequences: params.stop_sequences,
model_preferences: params.model_preferences,
include_context: params.include_context,
metadata: params.metadata,
tools: params.tools,
tool_choice: params.tool_choice,
}
}
fn elicit_request_params_from_embedded(
input_key: &str,
params: FinalEmbeddedElicitationParams,
) -> ElicitRequestParams {
match params {
FinalEmbeddedElicitationParams::Form(form) => {
ElicitRequestParams::Form(ElicitRequestFormParams {
mode: form.mode,
message: form.message,
requested_schema: form.requested_schema.schema().clone(),
})
}
FinalEmbeddedElicitationParams::Url(url) => {
ElicitRequestParams::Url(ElicitRequestUrlParams {
mode: url.mode,
message: url.message,
url: url.url.as_str().to_owned(),
elicitation_id: input_key.to_owned(),
})
}
}
}
/// Maximum input responses accepted for one MRTR continuation.
///
/// This is an individual continuation bound. The multi-round stdio driver also
/// limits cumulative entries across the complete MRTR operation.
pub const MAX_MRTR_INPUT_RESPONSES: usize = 128;
/// Maximum `input_required` continuations for one stdio MRTR operation.
pub const MAX_MRTR_CONTINUATION_ROUNDS: usize = 4;
/// Maximum response entries admitted across every continuation in one MRTR operation.
pub const MAX_MRTR_TOTAL_INPUT_RESPONSES: usize = MAX_MRTR_INPUT_RESPONSES;
fn mrtr_retry_parameters(
mut parameters: serde_json::Value,
input_required: &InputRequiredResult,
input_responses: MrtrInputResponses,
) -> McpResult<serde_json::Value> {
if input_responses.len() > MAX_MRTR_INPUT_RESPONSES {
return Err(McpError::invalid_params(format!(
"MRTR inputResponses must not exceed {MAX_MRTR_INPUT_RESPONSES} entries",
)));
}
let input_requests = input_required.input_requests();
if input_requests.is_none() && !input_responses.is_empty() {
return Err(McpError::invalid_params(
"MRTR inputResponses require peer inputRequests",
));
}
if let Some(input_requests) = input_requests {
for key in input_responses.keys() {
if !input_requests
.members()
.iter()
.any(|request| request.name == *key)
{
return Err(McpError::invalid_params(
"MRTR inputResponses contain a key not requested by the peer",
));
}
}
for request in input_requests.members() {
if !input_responses.contains_key(&request.name) {
return Err(McpError::invalid_params(
"MRTR inputResponses must include every key requested by the peer",
));
}
}
}
if input_responses.is_empty() && input_required.request_state().is_none() {
return Err(McpError::invalid_params(
"MRTR retry requires inputResponses or requestState",
));
}
let parameters = parameters
.as_object_mut()
.ok_or_else(|| McpError::internal_error("MRTR retry parameters must remain an object"))?;
if !input_responses.is_empty() {
parameters.insert(
"inputResponses".to_owned(),
serde_json::to_value(input_responses).map_err(|error| {
McpError::internal_error(format!(
"MRTR inputResponses could not serialize: {error}"
))
})?,
);
}
if let Some(request_state) = input_required.request_state() {
parameters.insert(
"requestState".to_owned(),
serde_json::Value::String(request_state.to_owned()),
);
}
Ok(serde_json::Value::Object(parameters.clone()))
}
fn mrtr_input_required_for_method<'a>(
method: &str,
result: &'a CoreResult,
) -> Option<&'a InputRequiredResult> {
match (method, result) {
(
"tools/call",
CoreResult::Final(FinalCoreResult::ToolsCallInputRequired { result, .. }),
)
| (
"resources/read",
CoreResult::Final(FinalCoreResult::ResourcesReadInputRequired { result, .. }),
)
| (
"prompts/get",
CoreResult::Final(FinalCoreResult::PromptsGetInputRequired { result, .. }),
) => Some(result),
_ => None,
}
}
#[cfg(feature = "websocket-experimental")]
fn require_terminal_websocket_mrtr_result(
method: &'static str,
result: FinalCoreResult,
) -> McpResult<FinalCoreResult> {
match (method, result) {
("tools/call", result @ FinalCoreResult::ToolsCall { .. })
| ("resources/read", result @ FinalCoreResult::ResourcesRead { .. })
| ("prompts/get", result @ FinalCoreResult::PromptsGet { .. }) => Ok(result),
("tools/call", FinalCoreResult::ToolsCallInputRequired { .. })
| ("resources/read", FinalCoreResult::ResourcesReadInputRequired { .. })
| ("prompts/get", FinalCoreResult::PromptsGetInputRequired { .. }) => {
Err(McpError::invalid_request(format!(
"Modern WebSocket MRTR {method} ended without a terminal result"
)))
}
#[cfg(feature = "tasks")]
("tools/call", FinalCoreResult::ToolsCallTask { .. }) => Err(McpError::invalid_request(
"Modern WebSocket MRTR tools/call ended without a terminal result",
)),
_ => Err(McpError::invalid_request(format!(
"Modern WebSocket MRTR received an unexpected terminal result for {method}"
))),
}
}
fn subscription_listener_protocol_error(message: &'static str) -> McpError {
McpError::invalid_request(message)
}
fn validate_subscription_acknowledgement_filter(
requested: &SubscriptionFilter,
acknowledged: &SubscriptionFilter,
) -> McpResult<()> {
for (requested, acknowledged) in [
(
requested.prompts_list_changed,
acknowledged.prompts_list_changed,
),
(
requested.resources_list_changed,
acknowledged.resources_list_changed,
),
(
requested.tools_list_changed,
acknowledged.tools_list_changed,
),
] {
match acknowledged {
None => {}
Some(true) if requested == Some(true) => {}
Some(_) => {
return Err(subscription_listener_protocol_error(
"Subscription acknowledgement accepts a notification category that was not requested",
));
}
}
}
if let Some(acknowledged_uris) = &acknowledged.resource_subscriptions {
let Some(requested_uris) = &requested.resource_subscriptions else {
return Err(subscription_listener_protocol_error(
"Subscription acknowledgement accepts resource updates that were not requested",
));
};
for (index, uri) in acknowledged_uris.iter().enumerate() {
if !requested_uris
.iter()
.any(|requested_uri| requested_uri == uri)
|| acknowledged_uris[..index]
.iter()
.any(|previous_uri| previous_uri == uri)
{
return Err(subscription_listener_protocol_error(
"Subscription acknowledgement contains an invalid resource update filter",
));
}
}
}
#[cfg(feature = "tasks")]
{
let requested_task_ids = task_subscription_ids(requested).map_err(|_| {
subscription_listener_protocol_error("Requested Tasks subscription filter is invalid")
})?;
let acknowledged_task_ids = task_subscription_ids(acknowledged).map_err(|_| {
subscription_listener_protocol_error(
"Subscription acknowledgement has an invalid Tasks filter",
)
})?;
match (requested_task_ids.as_ref(), acknowledged_task_ids.as_ref()) {
(None, Some(_)) => {
return Err(subscription_listener_protocol_error(
"Subscription acknowledgement accepts unrequested Tasks notifications",
));
}
(Some(requested), Some(acknowledged)) => {
for (index, task_id) in acknowledged.iter().enumerate() {
if !requested.iter().any(|requested| requested == task_id)
|| acknowledged[..index]
.iter()
.any(|previous| previous == task_id)
{
return Err(subscription_listener_protocol_error(
"Subscription acknowledgement contains an invalid Tasks filter",
));
}
}
}
(Some(_) | None, None) => {}
}
if acknowledged.additional.iter().any(|(name, value)| {
name != TASK_SUBSCRIPTION_IDS_KEY
&& requested
.additional
.get(name)
.is_none_or(|requested_value| requested_value != value)
}) {
return Err(subscription_listener_protocol_error(
"Subscription acknowledgement accepts an unrequested extension filter",
));
}
}
#[cfg(not(feature = "tasks"))]
if acknowledged.additional.iter().any(|(name, value)| {
requested
.additional
.get(name)
.is_none_or(|requested_value| requested_value != value)
}) {
return Err(subscription_listener_protocol_error(
"Subscription acknowledgement accepts an unrequested extension filter",
));
}
Ok(())
}
fn validate_subscription_acknowledgement(
expected_id: &RequestId,
requested: &SubscriptionFilter,
acknowledgement: &FinalSubscriptionsAcknowledgedNotificationParams,
) -> McpResult<()> {
let subscription_id = acknowledgement
.meta
.as_ref()
.and_then(|metadata| metadata.get(FINAL_SUBSCRIPTION_ID_META_KEY))
.ok_or_else(|| {
subscription_listener_protocol_error(
"Subscription acknowledgement is missing its subscription ID",
)
})
.and_then(|value| {
serde_json::from_value::<RequestId>(value.clone()).map_err(|_| {
subscription_listener_protocol_error(
"Subscription acknowledgement has an invalid subscription ID",
)
})
})?;
if !subscription_id.correlates_with(expected_id) {
return Err(subscription_listener_protocol_error(
"Subscription acknowledgement ID does not match the listen request",
));
}
validate_subscription_acknowledgement_filter(requested, &acknowledgement.notifications)
}
/// Returns the acknowledgement's subscription ID when it is a well-formed
/// JSON-RPC request identity.
fn subscription_acknowledgement_request_id(
acknowledgement: &FinalSubscriptionsAcknowledgedNotificationParams,
) -> Option<RequestId> {
acknowledgement
.meta
.as_ref()
.and_then(|metadata| metadata.get(FINAL_SUBSCRIPTION_ID_META_KEY))
.and_then(|value| serde_json::from_value(value.clone()).ok())
}
/// True when this acknowledgement carries a different listen-request identity.
///
/// Incremental catalog and Tasks listeners share one notification queue. A
/// well-formed ack for the other listener must stay on the queue; treating it
/// as a protocol failure (or dropping it) steals the other listener's ack.
fn subscription_acknowledgement_is_foreign(
expected_id: &RequestId,
acknowledgement: &FinalSubscriptionsAcknowledgedNotificationParams,
) -> bool {
subscription_acknowledgement_request_id(acknowledgement)
.is_some_and(|subscription_id| !subscription_id.correlates_with(expected_id))
}
fn catalog_subscription_requested(filter: &SubscriptionFilter) -> bool {
filter.tools_list_changed == Some(true)
|| filter.resources_list_changed == Some(true)
|| filter.prompts_list_changed == Some(true)
|| filter
.resource_subscriptions
.as_ref()
.is_some_and(|uris| !uris.is_empty())
}
fn validate_subscription_notification_filter(
notification: &ServerNotification,
accepted_filter: &SubscriptionFilter,
) -> McpResult<()> {
let accepted = match notification {
ServerNotification::ResourcesListChanged(_) => {
accepted_filter.resources_list_changed == Some(true)
}
ServerNotification::ToolsListChanged(_) => accepted_filter.tools_list_changed == Some(true),
ServerNotification::PromptsListChanged(_) => {
accepted_filter.prompts_list_changed == Some(true)
}
ServerNotification::ResourceUpdated(update) => accepted_filter
.resource_subscriptions
.as_ref()
.is_some_and(|uris| uris.iter().any(|uri| uri == update.uri.as_str())),
ServerNotification::Cancelled(_)
| ServerNotification::Progress(_)
| ServerNotification::Message(_)
| ServerNotification::SubscriptionsAcknowledged(_) => false,
};
if accepted {
Ok(())
} else {
Err(subscription_listener_protocol_error(
"Subscription stream emitted a notification outside its accepted filter",
))
}
}
#[cfg(feature = "tasks")]
fn negotiate_final_tasks_discovery(
discovery: &ServerDiscoverResult,
) -> McpResult<(
ExtensionDescriptorRegistry,
fastmcp_protocol::ExtensionId,
fastmcp_protocol::extensions::NegotiatedExtensionSet,
)> {
let capabilities = serde_json::to_value(discovery.capabilities()).map_err(|error| {
McpError::internal_error(format!(
"Failed to retain final Tasks capability discovery: {error}"
))
})?;
let settings_value = capabilities
.get("extensions")
.and_then(serde_json::Value::as_object)
.and_then(|extensions| extensions.get(fastmcp_protocol::TASKS_EXTENSION))
.cloned()
.ok_or_else(|| {
McpError::invalid_params(
"Server did not declare io.modelcontextprotocol/tasks capability",
)
})?;
let server_settings = ExtensionSettings::new(settings_value).map_err(|_| {
McpError::invalid_params(
"Server io.modelcontextprotocol/tasks settings are not an admitted object",
)
})?;
let mut registry = ExtensionDescriptorRegistry::new();
let task_extension = register_official_tasks_extension(&mut registry).map_err(|error| {
McpError::internal_error(format!(
"Failed to register the official Tasks client surface: {error}"
))
})?;
registry.freeze().map_err(|error| {
McpError::internal_error(format!(
"Failed to freeze the official Tasks client surface: {error}"
))
})?;
let mut local = ExtensionLocalEnablement::default();
local.enable(task_extension.clone());
let client = ClientExtensionDiscovery {
extensions: BTreeMap::from([(task_extension.clone(), official_tasks_empty_settings())]),
};
let server = ServerExtensionDiscovery {
extensions: BTreeMap::from([(task_extension.clone(), server_settings)]),
};
let mut resolve_empty_settings =
|_descriptor: &fastmcp_protocol::ExtensionDescriptor,
_client: &ExtensionSettings,
_server: &ExtensionSettings| { Ok(official_tasks_empty_settings()) };
let negotiated = registry
.negotiate(
ProtocolEra::Modern2026,
&local,
&client,
&server,
&mut resolve_empty_settings,
)
.map_err(|_| {
McpError::invalid_params(
"io.modelcontextprotocol/tasks requires bilateral empty settings",
)
})?;
Ok((registry, task_extension, negotiated))
}
#[cfg(feature = "tasks")]
fn admit_final_tasks_discovery_surface(
discovery: &ServerDiscoverResult,
name: &str,
direction: ExtensionDirection,
) -> McpResult<()> {
let (registry, task_extension, negotiated) = negotiate_final_tasks_discovery(discovery)?;
let admitted = if name == TASK_STATUS_NOTIFICATION {
negotiated
.admit_notification(
®istry,
ProtocolEra::Modern2026,
&task_extension,
name,
direction,
)
.map(|_| ())
} else {
negotiated
.admit_method(
®istry,
ProtocolEra::Modern2026,
&task_extension,
name,
direction,
)
.map(|_| ())
};
admitted.map_err(|_| {
McpError::invalid_params(
"Tasks surface is not admitted by the negotiated official extension",
)
})
}
#[cfg(feature = "tasks")]
fn admit_final_tasks_result_discriminator(
discovery: &ServerDiscoverResult,
discriminator: &str,
) -> McpResult<()> {
let (registry, task_extension, negotiated) = negotiate_final_tasks_discovery(discovery)?;
negotiated
.admit_result_discriminator(
®istry,
ProtocolEra::Modern2026,
&task_extension,
discriminator,
)
.map(|_| ())
.map_err(|_| {
McpError::invalid_params(
"Tasks result is not admitted by the negotiated official extension",
)
})
}
#[cfg(feature = "tasks")]
fn insert_negotiated_tasks_client_extension(
metadata: &mut serde_json::Map<String, serde_json::Value>,
discovery: Option<&ServerDiscoverResult>,
) -> McpResult<()> {
let Some(discovery) = discovery else {
return Ok(());
};
if admit_final_tasks_result_discriminator(discovery, OFFICIAL_TASKS_RESULT_DISCRIMINATOR)
.is_err()
{
return Ok(());
}
let capabilities = metadata
.get_mut(FINAL_CLIENT_CAPABILITIES_META_KEY)
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
McpError::internal_error("Modern request metadata omitted client capabilities")
})?;
let extensions = capabilities
.entry("extensions")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
.as_object_mut()
.ok_or_else(|| McpError::internal_error("Modern client extensions must be an object"))?;
extensions
.entry(fastmcp_protocol::TASKS_EXTENSION.to_owned())
.or_insert_with(|| serde_json::json!({}));
Ok(())
}
/// Overlays inbound sampling/elicitation/roots without replacing extension
/// advertisements already stamped on `_meta` client capabilities.
fn overlay_inbound_core_client_capabilities_on_metadata(
metadata: &mut serde_json::Map<String, serde_json::Value>,
inbound: &ClientCapabilities,
) -> McpResult<()> {
let inbound = serde_json::to_value(inbound).map_err(|_| {
McpError::internal_error("Inbound client capabilities could not be encoded")
})?;
let Some(inbound) = inbound.as_object() else {
return Err(McpError::internal_error(
"Inbound client capabilities must serialize as an object",
));
};
let capabilities = metadata
.entry(FINAL_CLIENT_CAPABILITIES_META_KEY.to_owned())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
let Some(capabilities) = capabilities.as_object_mut() else {
return Err(McpError::internal_error(
"Modern Tasks client capabilities must be an object",
));
};
for key in ["sampling", "elicitation", "roots"] {
match inbound.get(key) {
Some(value) => {
capabilities.insert(key.to_owned(), value.clone());
}
None => {
capabilities.remove(key);
}
}
}
Ok(())
}
fn insert_final_request_log_level(
metadata: &mut serde_json::Map<String, serde_json::Value>,
level: Option<LoggingLevel>,
) -> McpResult<()> {
let Some(level) = level else {
return Ok(());
};
metadata.insert(
FINAL_LOG_LEVEL_META_KEY.to_owned(),
serde_json::to_value(level).map_err(|error| {
McpError::internal_error(format!(
"Failed to serialize modern logging configuration: {error}"
))
})?,
);
Ok(())
}
fn final_log_level(level: LogLevel) -> LoggingLevel {
match level {
LogLevel::Debug => LoggingLevel::Debug,
LogLevel::Info => LoggingLevel::Info,
LogLevel::Notice => LoggingLevel::Notice,
LogLevel::Warning => LoggingLevel::Warning,
LogLevel::Error => LoggingLevel::Error,
LogLevel::Critical => LoggingLevel::Critical,
LogLevel::Alert => LoggingLevel::Alert,
LogLevel::Emergency => LoggingLevel::Emergency,
}
}
fn legacy_log_level(level: LoggingLevel) -> LogLevel {
match level {
LoggingLevel::Debug => LogLevel::Debug,
LoggingLevel::Info => LogLevel::Info,
LoggingLevel::Notice => LogLevel::Notice,
LoggingLevel::Warning => LogLevel::Warning,
LoggingLevel::Error => LogLevel::Error,
LoggingLevel::Critical => LogLevel::Critical,
LoggingLevel::Alert => LogLevel::Alert,
LoggingLevel::Emergency => LogLevel::Emergency,
}
}
const DEFAULT_CLIENT_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_CLIENT_ABSOLUTE_TIMEOUT: Duration = Duration::from_secs(120);
#[cfg(feature = "legacy-2024-11-05")]
const DEFAULT_STDIO_PROTOCOL_POLICY: ProtocolPolicy = ProtocolPolicy::Auto;
#[cfg(not(feature = "legacy-2024-11-05"))]
const DEFAULT_STDIO_PROTOCOL_POLICY: ProtocolPolicy = ProtocolPolicy::ModernOnly;
const MAX_CLIENT_IDLE_TIMEOUT: Duration = Duration::from_mins(5);
const MAX_CLIENT_ABSOLUTE_TIMEOUT: Duration = Duration::from_mins(15);
const DIRECT_CHILD_REAP_TIMEOUT: Duration = Duration::from_secs(2);
const DIRECT_CHILD_REAP_POLL_INTERVAL: Duration = Duration::from_millis(10);
const OWNED_PROCESS_GROUP_QUIESCENCE_TIMEOUT: Duration = Duration::from_secs(2);
#[cfg(target_os = "linux")]
const OWNED_PROCESS_GROUP_INSPECTION_TIMEOUT: Duration = Duration::from_secs(1);
#[cfg(target_os = "linux")]
const LINUX_PROC_MOUNTS_MAX_BYTES: u64 = 256 * 1024;
#[cfg(target_os = "linux")]
const LINUX_PROC_STAT_MAX_BYTES: u64 = 64 * 1024;
#[cfg(target_os = "linux")]
const LINUX_PROC_STATUS_MAX_BYTES: u64 = 256 * 1024;
const PROCESS_GROUP_ANCHOR_READY_TIMEOUT: Duration = Duration::from_secs(2);
const CLEANUP_UNVERIFIED_DATA_KEY: &str = "fastmcpCleanupUnverified";
const CLEANUP_DURATION_MS_DATA_KEY: &str = "cleanupDurationMs";
/// Idle and absolute limits for the response-wait phase of one ordinary client
/// request.
///
/// Both timers start after the request send commits; they do not bound a
/// blocking send or later connection teardown. Both limits are nonzero and
/// bounded. The idle timer may be restarted by a valid, strictly increasing
/// progress notification carrying the request's exact progress token when
/// [`Self::reset_idle_on_matching_progress`] is enabled. The absolute timer
/// never moves.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RequestTimeoutPolicy {
idle_timeout: Duration,
absolute_timeout: Duration,
reset_idle_on_matching_progress: bool,
}
impl RequestTimeoutPolicy {
/// Creates and validates an ordinary-request timeout policy.
///
/// # Errors
///
/// Returns an invalid-parameters error when idle is below 1 millisecond or
/// exceeds 5 minutes, or absolute is below 1 millisecond or exceeds
/// 15 minutes.
pub fn new(idle_timeout: Duration, absolute_timeout: Duration) -> McpResult<Self> {
let policy = Self {
idle_timeout,
absolute_timeout,
reset_idle_on_matching_progress: true,
};
policy.validate()?;
Ok(policy)
}
/// Selects whether exact, valid, strictly increasing matching progress
/// restarts the idle timer. This never changes the absolute timer.
#[must_use]
pub const fn reset_idle_on_matching_progress(mut self, enabled: bool) -> Self {
self.reset_idle_on_matching_progress = enabled;
self
}
/// Returns the idle timeout.
#[must_use]
pub const fn idle_timeout(self) -> Duration {
self.idle_timeout
}
/// Returns the non-resettable absolute timeout.
#[must_use]
pub const fn absolute_timeout(self) -> Duration {
self.absolute_timeout
}
/// Returns whether valid matching progress restarts the idle timer.
#[must_use]
pub const fn resets_idle_on_matching_progress(self) -> bool {
self.reset_idle_on_matching_progress
}
fn validate(self) -> McpResult<()> {
validate_timeout_duration(
self.idle_timeout,
MAX_CLIENT_IDLE_TIMEOUT,
"Client request idle timeout must be between 1 millisecond and 5 minutes",
)?;
validate_timeout_duration(
self.absolute_timeout,
MAX_CLIENT_ABSOLUTE_TIMEOUT,
"Client request absolute timeout must be between 1 millisecond and 15 minutes",
)
}
}
impl Default for RequestTimeoutPolicy {
fn default() -> Self {
Self {
idle_timeout: DEFAULT_CLIENT_IDLE_TIMEOUT,
absolute_timeout: DEFAULT_CLIENT_ABSOLUTE_TIMEOUT,
reset_idle_on_matching_progress: true,
}
}
}
/// The request-local timer that selected a timeout outcome.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RequestTimeoutSource {
/// No valid request-owned activity arrived before the idle bound.
Idle,
/// The non-resettable post-commit response-wait lifetime elapsed.
Absolute,
}
fn request_timeout_error(source: RequestTimeoutSource) -> McpError {
let (message, source_name) = match source {
RequestTimeoutSource::Idle => ("Request timed out at the idle deadline", "idle"),
RequestTimeoutSource::Absolute => {
("Request timed out at the absolute deadline", "absolute")
}
};
McpError::with_data(
McpErrorCode::InternalError,
message,
serde_json::json!({"timeoutSource": source_name}),
)
}
#[derive(Clone, Copy, Debug)]
struct RequestDeadlines {
idle: Instant,
absolute: Instant,
idle_timeout: Duration,
}
impl RequestDeadlines {
fn start_at(policy: RequestTimeoutPolicy, committed_at: Instant) -> McpResult<Self> {
policy.validate()?;
let idle_timeout = policy.idle_timeout;
let idle = committed_at.checked_add(idle_timeout).ok_or_else(|| {
McpError::internal_error("Request idle timeout exceeds the clock range")
})?;
let absolute = committed_at
.checked_add(policy.absolute_timeout)
.ok_or_else(|| {
McpError::internal_error("Request absolute timeout exceeds the clock range")
})?;
Ok(Self {
idle,
absolute,
idle_timeout,
})
}
fn next(self) -> Instant {
self.idle.min(self.absolute)
}
fn next_kind(self) -> RequestTimeoutSource {
if self.absolute <= self.idle {
RequestTimeoutSource::Absolute
} else {
RequestTimeoutSource::Idle
}
}
fn expired_at(self, observed_at: Instant) -> Option<RequestTimeoutSource> {
if observed_at >= self.absolute && self.absolute <= self.idle {
Some(RequestTimeoutSource::Absolute)
} else if observed_at >= self.idle {
Some(RequestTimeoutSource::Idle)
} else if observed_at >= self.absolute {
Some(RequestTimeoutSource::Absolute)
} else {
None
}
}
fn reset_idle_at(&mut self, observed_at: Instant) -> McpResult<()> {
self.idle = observed_at.checked_add(self.idle_timeout).ok_or_else(|| {
McpError::internal_error("Request idle timeout exceeds the clock range")
})?;
Ok(())
}
fn cap_absolute_at(&mut self, deadline: Instant) {
self.absolute = self.absolute.min(deadline);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DirectChildStopDecision {
/// The direct child is still known to be live and may be terminated safely.
TerminateAndReap,
/// The child is already reaped, or its identity can no longer be proven.
DoNotSignal,
}
/// Defines the subprocess resource that a client is responsible for stopping.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum ChildOwnership {
/// Only the exact child handle is owned.
#[default]
DirectChild,
/// The peer is a member of a dedicated Unix process group whose separate
/// live anchor pins the PGID and owns an owner-death control pipe.
OwnedProcessGroup,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum ClientChildCleanupPhase {
#[default]
Active,
#[cfg(unix)]
GroupKillAccepted(rustix::process::Pid),
#[cfg(unix)]
GroupChildrenReaped(rustix::process::Pid),
#[cfg(unix)]
GroupIdentityLost(rustix::process::Pid),
Complete,
}
#[cfg(unix)]
const PROCESS_GROUP_ANCHOR_SCRIPT: &str = r"
trap '' HUP INT TERM
printf R
exec 1>&-
while IFS= read -r _; do :; done
kill -s KILL 0
exit 127
";
/// A live process-group leader controlled by a close-on-exec pipe.
///
/// The requested MCP peer is spawned directly as this anchor's sibling, so
/// the peer retains the exact executable, argv, environment, working
/// directory, and stdio behavior requested by the caller. Only this owner
/// process retains `control`; EOF therefore tells the anchor that the owner
/// closed normally or died, at which point the anchor kills its own group.
pub(crate) struct ProcessGroupAnchor {
#[cfg(unix)]
child: Option<Child>,
#[cfg(unix)]
control: Option<OwnedFd>,
#[cfg(unix)]
process_group: rustix::process::Pid,
}
impl ProcessGroupAnchor {
#[cfg(unix)]
pub(crate) fn spawn() -> McpResult<Self> {
if !Path::new("/bin/sh").is_file() {
return Err(McpError::internal_error(
"Owned subprocess groups require /bin/sh on this Unix platform",
));
}
// Standard-library Unix sockets are marked close-on-exec and remain
// available on Apple targets, where rustix intentionally omits its
// atomic `pipe_with` API. Apple applies CLOEXEC after `socketpair`, so
// a concurrent host-side raw fork during this short setup window can
// retain a copy; the public ownership contract documents that limit.
// Each pair is used only as a one-way channel.
let (control_reader, control_writer) = UnixStream::pair().map_err(|error| {
McpError::internal_error(format!(
"Failed to create the process-group anchor control channel: {error}"
))
})?;
let (ready_reader, ready_writer) = UnixStream::pair().map_err(|error| {
McpError::internal_error(format!(
"Failed to create the process-group anchor readiness channel: {error}"
))
})?;
let control_reader = OwnedFd::from(control_reader);
let control_writer = OwnedFd::from(control_writer);
let ready_reader = OwnedFd::from(ready_reader);
let ready_writer = OwnedFd::from(ready_writer);
let mut command = Command::new("/bin/sh");
command
.arg("-c")
.arg(PROCESS_GROUP_ANCHOR_SCRIPT)
.arg("fastmcp-process-group-anchor")
.stdin(Stdio::from(control_reader))
.stdout(Stdio::from(ready_writer))
.stderr(Stdio::null())
.env_clear()
.process_group(0);
let child = command.spawn().map_err(|error| {
McpError::internal_error(format!("Failed to spawn the process-group anchor: {error}"))
})?;
let raw_group_id = i32::try_from(child.id()).map_err(|_| {
McpError::internal_error("Owned process-group identifier exceeds the platform range")
})?;
let process_group = rustix::process::Pid::from_raw(raw_group_id)
.ok_or_else(|| McpError::internal_error("Owned process-group identifier is invalid"))?;
let mut anchor = Self {
child: Some(child),
control: Some(control_writer),
process_group,
};
match Self::wait_until_ready(&ready_reader) {
Ok(()) => Ok(anchor),
Err(error) => combine_operation_with_cleanup(Err(error), || anchor.cleanup()),
}
}
#[cfg(unix)]
fn wait_until_ready(ready_reader: &OwnedFd) -> McpResult<()> {
let deadline = Instant::now() + PROCESS_GROUP_ANCHOR_READY_TIMEOUT;
loop {
let now = Instant::now();
if now >= deadline {
return Err(McpError::internal_error(
"Process-group anchor did not become ready within the startup deadline",
));
}
let timeout =
rustix::event::Timespec::try_from(deadline.saturating_duration_since(now))
.map_err(|_| {
McpError::internal_error("Anchor readiness deadline is out of range")
})?;
let mut poll_fds = [rustix::event::PollFd::new(
ready_reader,
rustix::event::PollFlags::IN,
)];
match rustix::event::poll(&mut poll_fds, Some(&timeout)) {
Ok(0) => {
return Err(McpError::internal_error(
"Process-group anchor did not become ready within the startup deadline",
));
}
Ok(_) => {}
Err(rustix::io::Errno::INTR) => continue,
Err(error) => {
return Err(McpError::internal_error(format!(
"Failed while waiting for process-group anchor readiness: {error}"
)));
}
}
let mut marker = [0_u8; 1];
match rustix::io::read(ready_reader, &mut marker) {
Ok(1) if marker[0] == b'R' => return Ok(()),
Ok(0) => {
return Err(McpError::internal_error(
"Process-group anchor exited before reporting readiness",
));
}
Ok(_) => {
return Err(McpError::internal_error(
"Process-group anchor emitted an invalid readiness marker",
));
}
Err(rustix::io::Errno::INTR) => continue,
Err(error) => {
return Err(McpError::internal_error(format!(
"Failed to read process-group anchor readiness: {error}"
)));
}
}
}
}
#[cfg(unix)]
pub(crate) fn raw_process_group(&self) -> i32 {
self.process_group.as_raw_nonzero().get()
}
#[cfg(unix)]
fn verify_live(&mut self) -> McpResult<()> {
let Some(child) = self.child.as_mut() else {
return Err(McpError::internal_error(
"Process-group anchor handle is missing",
));
};
match child.try_wait() {
Ok(None) => Ok(()),
Ok(Some(status)) => {
self.child = None;
Err(McpError::internal_error(format!(
"Process-group anchor exited unexpectedly with {status}"
)))
}
Err(error) => Err(McpError::internal_error(format!(
"Failed to verify process-group anchor liveness: {error}"
))),
}
}
#[cfg(not(unix))]
fn verify_live(&mut self) -> McpResult<()> {
Err(McpError::internal_error(
"Owned subprocess groups are unavailable on this platform",
))
}
#[cfg(unix)]
fn request_shutdown(&mut self) {
// Closing the only post-exec writer produces EOF in the anchor and
// arms the owner-death fallback. Explicit cleanup first signals while
// the live anchor pins the PGID, so a stopped peer cannot also stop
// the only process capable of observing this EOF.
self.control.take();
}
#[cfg(unix)]
fn reap(&mut self) -> McpResult<()> {
let Some(child) = self.child.as_mut() else {
return Ok(());
};
reap_signalled_child(child)?;
self.child = None;
Ok(())
}
#[cfg(unix)]
pub(crate) fn cleanup(&mut self) -> McpResult<()> {
match request_anchored_group_shutdown(self)? {
AnchoredGroupShutdown::KillAccepted(process_group) => {
let reap_result = self.reap();
let group_result = wait_for_owned_process_group_quiescence(process_group);
combine_cleanup_results(reap_result, group_result)
}
AnchoredGroupShutdown::IdentityLost(process_group) => {
require_owned_process_group_absent(process_group)
}
}
}
#[cfg(not(unix))]
pub(crate) fn cleanup(&mut self) -> McpResult<()> {
Err(McpError::internal_error(
"Owned subprocess groups are unavailable on this platform",
))
}
}
impl Drop for ProcessGroupAnchor {
fn drop(&mut self) {
#[cfg(unix)]
{
if let Err(error) = self.cleanup() {
// Dropping the control writer below still arms the anchor's
// owner-death kill fallback. Verification failures remain
// observable only through explicit `Client::close`; Drop
// cannot return an error or create an orphan cleanup task.
log::error!("Process-group anchor cleanup was not verified: {error}");
}
}
}
}
fn direct_child_stop_decision(
probe: &std::io::Result<Option<ExitStatus>>,
) -> DirectChildStopDecision {
match probe {
Ok(None) => DirectChildStopDecision::TerminateAndReap,
Ok(Some(_)) | Err(_) => DirectChildStopDecision::DoNotSignal,
}
}
fn reap_signalled_child(child: &mut Child) -> McpResult<()> {
let reap_deadline = Instant::now() + DIRECT_CHILD_REAP_TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(_)) => return Ok(()),
Ok(None) => {}
#[cfg(unix)]
Err(error) if error.raw_os_error() == Some(rustix::io::Errno::CHILD.raw_os_error()) => {
// Once group shutdown has been requested, a process-wide
// reaper consuming this exact child is equivalent to a
// successful reap. This helper is never used to establish
// pre-signal identity.
return Ok(());
}
Err(error) => {
return Err(McpError::internal_error(format!(
"Failed to reap the owned subprocess: {error}"
)));
}
}
let now = Instant::now();
if now >= reap_deadline {
return Err(McpError::internal_error(
"Owned subprocess did not exit within the cleanup deadline",
));
}
std::thread::park_timeout(
reap_deadline
.saturating_duration_since(now)
.min(DIRECT_CHILD_REAP_POLL_INTERVAL),
);
}
}
/// Terminates and boundedly reaps the retained direct child process when its
/// identity is still proven by a successful live-status probe.
///
/// Descendant-tree ownership is deliberately not claimed here. Implementing
/// that safely and portably requires runtime support (including Windows Job
/// Objects), not a PATH-resolved helper and a reusable PID.
fn stop_direct_child(child: &mut Child) -> McpResult<()> {
let probe = child.try_wait();
match (&probe, direct_child_stop_decision(&probe)) {
(Ok(Some(_)), DirectChildStopDecision::DoNotSignal) => return Ok(()),
(Err(error), DirectChildStopDecision::DoNotSignal) => {
return Err(McpError::internal_error(format!(
"Failed to establish owned subprocess state: {error}"
)));
}
(_, DirectChildStopDecision::TerminateAndReap) => {}
(Ok(None), DirectChildStopDecision::DoNotSignal) => unreachable!(),
}
// Signal exactly once while the unreaped child handle still pins the
// process identity. Whether signalling succeeds or fails, only observe
// afterwards: a failed signal is not authority to target a potentially
// recycled PID, and a blocking `wait` would defeat request deadlines.
if let Err(signal_error) = child.kill() {
return match child.try_wait() {
Ok(Some(_)) => Ok(()),
Ok(None) => Err(McpError::internal_error(format!(
"Failed to terminate the owned subprocess: {signal_error}"
))),
Err(probe_error) => Err(McpError::internal_error(format!(
"Failed to terminate the owned subprocess ({signal_error}) and could not re-check its state ({probe_error})"
))),
};
}
reap_signalled_child(child)
}
#[cfg(unix)]
fn owned_process_group_is_absent(process_group: rustix::process::Pid) -> McpResult<bool> {
match rustix::process::test_kill_process_group(process_group) {
Err(rustix::io::Errno::SRCH) => Ok(true),
Ok(()) => Ok(false),
Err(error) => Err(McpError::internal_error(format!(
"Failed to verify owned subprocess-group cleanup: {error}"
))),
}
}
#[cfg(target_os = "linux")]
fn linux_ascii_fields(bytes: &[u8]) -> impl Iterator<Item = &[u8]> {
bytes
.split(|byte| byte.is_ascii_whitespace())
.filter(|field| !field.is_empty())
}
#[cfg(target_os = "linux")]
fn linux_process_state_group_and_thread_count(stat: &[u8]) -> Option<(char, i32, u64)> {
let command_end = stat.iter().rposition(|byte| *byte == b')')?;
let mut fields = linux_ascii_fields(stat.get(command_end + 1..)?);
let state = fields.next()?;
if state.len() != 1 {
return None;
}
let state = char::from(state[0]);
let _parent_process_id = fields.next()?;
let process_group_id = std::str::from_utf8(fields.next()?).ok()?.parse().ok()?;
let thread_count = std::str::from_utf8(fields.nth(14)?).ok()?.parse().ok()?;
Some((state, process_group_id, thread_count))
}
#[cfg(target_os = "linux")]
fn linux_proc_stat_process_id(stat: &[u8]) -> Option<u32> {
let command_start = stat.iter().position(|byte| *byte == b'(')?;
let mut fields = linux_ascii_fields(stat.get(..command_start)?);
let process_id = std::str::from_utf8(fields.next()?).ok()?.parse().ok()?;
fields.next().is_none().then_some(process_id)
}
#[cfg(target_os = "linux")]
fn linux_status_has_single_current_namespace_pid(status: &[u8], process_id: u32) -> bool {
let mut observed = None;
for line in status.split(|byte| *byte == b'\n') {
let Some(values) = line.strip_prefix(b"NSpid:") else {
continue;
};
if observed.is_some() {
return false;
}
let mut fields = linux_ascii_fields(values);
let Some(field) = fields.next() else {
return false;
};
if fields.next().is_some() {
return false;
}
observed = std::str::from_utf8(field)
.ok()
.and_then(|field| field.parse::<u32>().ok());
if observed.is_none() {
return false;
}
}
observed == Some(process_id)
}
#[cfg(target_os = "linux")]
fn linux_process_state_is_live(state: char) -> bool {
!matches!(state, 'Z' | 'X' | 'x')
}
#[cfg(target_os = "linux")]
fn linux_process_stat_proves_single_terminal_task(state: char, thread_count: u64) -> bool {
!linux_process_state_is_live(state) && thread_count == 1
}
#[cfg(target_os = "linux")]
fn linux_proc_process_disappeared(error: &std::io::Error) -> bool {
error.kind() == std::io::ErrorKind::NotFound
|| error.raw_os_error() == Some(rustix::io::Errno::SRCH.raw_os_error())
}
#[cfg(target_os = "linux")]
fn linux_proc_mounts_allow_complete_process_view(mounts: &str) -> bool {
let mut proc_mount_options = None;
for line in mounts.lines() {
let mut fields = line.split_ascii_whitespace();
let Some(_source) = fields.next() else {
continue;
};
let Some(mount_point) = fields.next() else {
continue;
};
let Some(file_system) = fields.next() else {
continue;
};
let Some(options) = fields.next() else {
continue;
};
if mount_point != "/proc" {
continue;
}
if file_system != "proc" || proc_mount_options.is_some() {
return false;
}
proc_mount_options = Some(options);
}
proc_mount_options.is_some_and(|options| {
!options
.split(',')
.any(|option| option.starts_with("hidepid=") && option != "hidepid=0")
})
}
#[cfg(target_os = "linux")]
fn linux_proc_file_mount_id(file: &std::fs::File) -> McpResult<u64> {
let metadata = rustix::fs::statx(
file,
"",
rustix::fs::AtFlags::EMPTY_PATH,
rustix::fs::StatxFlags::MNT_ID,
)
.map_err(|_| McpError::internal_error("Process-group live-member inspection failed"))?;
if metadata.stx_mask & rustix::fs::StatxFlags::MNT_ID.bits() == 0 || metadata.stx_mnt_id == 0 {
return Err(McpError::internal_error(
"Process-group live-member inspection requires procfs mount identity support",
));
}
Ok(metadata.stx_mnt_id)
}
#[cfg(target_os = "linux")]
fn linux_verify_proc_file_mount(file: &std::fs::File, proc_mount_id: u64) -> McpResult<()> {
if linux_proc_file_mount_id(file)? == proc_mount_id {
Ok(())
} else {
Err(McpError::internal_error(
"Process-group live-member inspection found an inconsistent procfs mount",
))
}
}
#[cfg(target_os = "linux")]
fn linux_read_bounded_proc_file(file: &std::fs::File, max_bytes: u64) -> McpResult<Vec<u8>> {
let mut bytes = Vec::new();
file.take(max_bytes.saturating_add(1))
.read_to_end(&mut bytes)
.map_err(|_| McpError::internal_error("Process-group live-member inspection failed"))?;
let length = u64::try_from(bytes.len())
.map_err(|_| McpError::internal_error("Process-group live-member inspection failed"))?;
if length > max_bytes {
return Err(McpError::internal_error(
"Process-group live-member inspection exceeded a procfs record bound",
));
}
Ok(bytes)
}
#[cfg(target_os = "linux")]
fn linux_open_verified_proc_file(path: &str, proc_mount_id: u64) -> McpResult<std::fs::File> {
let file = std::fs::File::open(path)
.map_err(|_| McpError::internal_error("Process-group live-member inspection failed"))?;
linux_verify_proc_file_mount(&file, proc_mount_id)?;
Ok(file)
}
#[cfg(target_os = "linux")]
fn verify_linux_procfs_process_view(deadline: Instant) -> McpResult<u64> {
if Instant::now() >= deadline {
return Err(McpError::internal_error(
"Process-group live-member inspection exceeded its deadline",
));
}
let proc_root = std::fs::File::open("/proc")
.map_err(|_| McpError::internal_error("Process-group live-member inspection failed"))?;
let proc_mount_id = linux_proc_file_mount_id(&proc_root)?;
let mounts_file = linux_open_verified_proc_file("/proc/self/mounts", proc_mount_id)?;
let mounts = linux_read_bounded_proc_file(&mounts_file, LINUX_PROC_MOUNTS_MAX_BYTES)?;
let mounts = std::str::from_utf8(&mounts)
.map_err(|_| McpError::internal_error("Process-group live-member inspection failed"))?;
if !linux_proc_mounts_allow_complete_process_view(mounts) {
return Err(McpError::internal_error(
"Process-group live-member inspection requires an unrestricted procfs view",
));
}
let self_stat_file = linux_open_verified_proc_file("/proc/self/stat", proc_mount_id)?;
let self_stat = linux_read_bounded_proc_file(&self_stat_file, LINUX_PROC_STAT_MAX_BYTES)?;
let process_id = std::process::id();
if linux_proc_stat_process_id(&self_stat) != Some(process_id) {
return Err(McpError::internal_error(
"Process-group live-member inspection found a mismatched procfs namespace",
));
}
let self_status_file = linux_open_verified_proc_file("/proc/self/status", proc_mount_id)?;
let self_status = linux_read_bounded_proc_file(&self_status_file, LINUX_PROC_STATUS_MAX_BYTES)?;
if !linux_status_has_single_current_namespace_pid(&self_status, process_id) {
return Err(McpError::internal_error(
"Process-group live-member inspection requires procfs mounted in the current PID namespace",
));
}
if Instant::now() >= deadline {
return Err(McpError::internal_error(
"Process-group live-member inspection exceeded its deadline",
));
}
Ok(proc_mount_id)
}
/// Observes whether a Linux process group currently has a live member.
///
/// This is a read-only workspace utility for process owners that already hold
/// separate authority over the group. `false` means the group was absent or
/// every observed member was a single-threaded terminal zombie for this
/// snapshot; it does not establish ownership and never sends a signal. The
/// scan fails closed for invalid identifiers, restricted or inconsistent
/// procfs views, namespace mismatch, ambiguous dead thread-group leaders,
/// observation races, and deadline expiry.
///
/// # Errors
///
/// Returns an error when a complete, unambiguous procfs snapshot cannot be
/// established before `deadline`.
#[cfg(target_os = "linux")]
#[doc(hidden)]
pub fn linux_process_group_has_live_member(
process_group_id: i32,
deadline: Instant,
) -> McpResult<bool> {
if process_group_id <= 0 {
return Err(McpError::internal_error(
"Process-group live-member inspection received an invalid identifier",
));
}
let process_group = rustix::process::Pid::from_raw(process_group_id).ok_or_else(|| {
McpError::internal_error(
"Process-group live-member inspection received an invalid identifier",
)
})?;
let proc_mount_id = verify_linux_procfs_process_view(deadline)?;
let processes = std::fs::read_dir("/proc")
.map_err(|_| McpError::internal_error("Process-group live-member inspection failed"))?;
let mut observed_matching_member = false;
for entry in processes {
if Instant::now() >= deadline {
return Err(McpError::internal_error(
"Process-group live-member inspection exceeded its deadline",
));
}
let entry = match entry {
Ok(entry) => entry,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(_) => {
return Err(McpError::internal_error(
"Process-group live-member inspection failed",
));
}
};
if entry.file_name().to_string_lossy().parse::<u32>().is_err() {
continue;
}
let stat_file = match std::fs::File::open(entry.path().join("stat")) {
Ok(file) => file,
Err(error) if linux_proc_process_disappeared(&error) => continue,
Err(_) => {
return Err(McpError::internal_error(
"Process-group live-member inspection failed",
));
}
};
linux_verify_proc_file_mount(&stat_file, proc_mount_id)?;
let stat = linux_read_bounded_proc_file(&stat_file, LINUX_PROC_STAT_MAX_BYTES)?;
let (state, observed_group_id, thread_count) =
linux_process_state_group_and_thread_count(&stat).ok_or_else(|| {
McpError::internal_error("Process-group live-member inspection failed")
})?;
if observed_group_id != process_group_id {
continue;
}
observed_matching_member = true;
if linux_process_state_is_live(state) {
return Ok(true);
}
if linux_process_stat_proves_single_terminal_task(state, thread_count) {
continue;
}
// `/proc` root enumeration exposes only thread-group leaders. A dead
// leader with any thread count other than exactly one is ambiguous:
// live siblings may exist even when `/proc/<tgid>/task` is unavailable.
return Err(McpError::internal_error(
"Process-group live-member inspection found an ambiguous terminal member",
));
}
if Instant::now() >= deadline {
return Err(McpError::internal_error(
"Process-group live-member inspection exceeded its deadline",
));
}
if observed_matching_member || owned_process_group_is_absent(process_group)? {
Ok(false)
} else {
Err(McpError::internal_error(
"Process-group live-member inspection could not reconcile procfs with the kernel group probe",
))
}
}
#[cfg(unix)]
fn require_owned_process_group_absent(process_group: rustix::process::Pid) -> McpResult<()> {
if owned_process_group_is_absent(process_group)? {
Ok(())
} else {
Err(McpError::internal_error(
"Owned process-group identity was lost while the group remained present; refusing to signal an unpinned PGID",
))
}
}
#[cfg(unix)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AnchoredGroupShutdown {
KillAccepted(rustix::process::Pid),
IdentityLost(rustix::process::Pid),
}
#[cfg(unix)]
fn request_anchored_group_shutdown(
anchor: &mut ProcessGroupAnchor,
) -> McpResult<AnchoredGroupShutdown> {
let process_group = anchor.process_group;
let Some(child) = anchor.child.as_mut() else {
anchor.request_shutdown();
return Ok(AnchoredGroupShutdown::IdentityLost(process_group));
};
match child.try_wait() {
Ok(None) => {
// The live anchor pins this PGID. Signal while that proof is held;
// closing the control pipe afterwards also arms owner-death
// fallback if the shell had not yet observed the signal.
rustix::process::kill_process_group(process_group, rustix::process::Signal::KILL)
.map_err(|error| {
McpError::internal_error(format!(
"Failed to terminate the anchored subprocess group: {error}"
))
})?;
anchor.request_shutdown();
Ok(AnchoredGroupShutdown::KillAccepted(process_group))
}
Ok(Some(_)) => {
anchor.child = None;
anchor.request_shutdown();
Ok(AnchoredGroupShutdown::IdentityLost(process_group))
}
Err(error) if error.raw_os_error() == Some(rustix::io::Errno::CHILD.raw_os_error()) => {
anchor.child = None;
anchor.request_shutdown();
Ok(AnchoredGroupShutdown::IdentityLost(process_group))
}
Err(error) => Err(McpError::internal_error(format!(
"Failed to establish process-group anchor state: {error}"
))),
}
}
#[cfg(unix)]
fn wait_for_owned_process_group_quiescence(process_group: rustix::process::Pid) -> McpResult<()> {
let deadline = Instant::now() + OWNED_PROCESS_GROUP_QUIESCENCE_TIMEOUT;
loop {
if owned_process_group_is_absent(process_group)? {
return Ok(());
}
let now = Instant::now();
if now >= deadline {
#[cfg(target_os = "linux")]
{
// Linux keeps zombie-only groups observable through
// `kill(-pgid, 0)`. After the anchored kill was accepted and
// both direct children were reaped, accept delayed orphan
// reaping only after two independent complete snapshots prove
// that no live member remains.
let process_group_id = process_group.as_raw_nonzero().get();
let first_deadline = Instant::now()
.checked_add(OWNED_PROCESS_GROUP_INSPECTION_TIMEOUT)
.unwrap_or_else(Instant::now);
if !linux_process_group_has_live_member(process_group_id, first_deadline)? {
std::thread::park_timeout(DIRECT_CHILD_REAP_POLL_INTERVAL);
let second_deadline = Instant::now()
.checked_add(OWNED_PROCESS_GROUP_INSPECTION_TIMEOUT)
.unwrap_or_else(Instant::now);
if !linux_process_group_has_live_member(process_group_id, second_deadline)? {
return Ok(());
}
}
}
return Err(McpError::internal_error(
"Owned subprocess group remained present after the cleanup deadline",
));
}
std::thread::park_timeout(
deadline
.saturating_duration_since(now)
.min(DIRECT_CHILD_REAP_POLL_INTERVAL),
);
}
}
#[cfg(unix)]
fn stop_owned_process_group(child: &mut Child, anchor: &mut ProcessGroupAnchor) -> McpResult<()> {
match request_anchored_group_shutdown(anchor)? {
AnchoredGroupShutdown::KillAccepted(process_group) => {
// Reap both direct children before the final non-signalling probe
// so their zombies cannot keep the group observable.
let peer_result = reap_signalled_child(child);
let anchor_result = anchor.reap();
let group_result = wait_for_owned_process_group_quiescence(process_group);
combine_cleanup_results(
combine_cleanup_results(peer_result, anchor_result),
group_result,
)
}
AnchoredGroupShutdown::IdentityLost(process_group) => {
// Without a live anchor, signal only the exact retained peer. The
// old numeric PGID is now observation-only because it may be
// recycled for an unrelated group.
let peer_result = stop_direct_child(child);
let group_result = require_owned_process_group_absent(process_group);
combine_cleanup_results(peer_result, group_result)
}
}
}
#[cfg(not(unix))]
fn stop_owned_process_group(_child: &mut Child, _anchor: &mut ProcessGroupAnchor) -> McpResult<()> {
Err(McpError::internal_error(
"Owned subprocess groups are unavailable on this platform",
))
}
fn stop_child(
child: &mut Child,
ownership: ChildOwnership,
group_anchor: &mut Option<ProcessGroupAnchor>,
) -> McpResult<()> {
match ownership {
ChildOwnership::DirectChild => stop_direct_child(child),
ChildOwnership::OwnedProcessGroup => group_anchor.as_mut().map_or_else(
|| {
Err(McpError::internal_error(
"Owned process-group anchor is missing",
))
},
|anchor| stop_owned_process_group(child, anchor),
),
}
}
fn combine_cleanup_errors(first: McpError, second: McpError) -> McpError {
McpError::internal_error(format!(
"Multiple client cleanup steps failed ({first}); ({second})"
))
}
pub(crate) fn combine_cleanup_results(
first: McpResult<()>,
second: McpResult<()>,
) -> McpResult<()> {
match (first, second) {
(Ok(()), Ok(())) => Ok(()),
(Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error),
(Err(first), Err(second)) => Err(combine_cleanup_errors(first, second)),
}
}
pub(crate) fn combine_operation_and_cleanup<T>(
operation: McpResult<T>,
cleanup: McpResult<()>,
) -> McpResult<T> {
match (operation, cleanup) {
(Ok(value), Ok(())) => Ok(value),
(Err(error), Ok(())) => Err(error),
(Ok(_), Err(cleanup_error)) => Err(mark_cleanup_unverified(cleanup_error)),
(Err(operation_error), Err(cleanup_error)) => Err(McpError::with_data(
McpErrorCode::InternalError,
format!("Client cleanup failed after an operation failure: {cleanup_error}"),
serde_json::json!({
CLEANUP_UNVERIFIED_DATA_KEY: true,
"operation": operation_error,
"cleanup": cleanup_error,
}),
)),
}
}
pub(crate) fn combine_operation_with_cleanup<T, F>(
operation: McpResult<T>,
cleanup: F,
) -> McpResult<T>
where
F: FnOnce() -> McpResult<()>,
{
let started = Instant::now();
let mut result = combine_operation_and_cleanup(operation, cleanup());
if let Err(error) = &mut result
&& is_cleanup_unverified(error)
&& let Some(data) = error
.data
.as_mut()
.and_then(serde_json::Value::as_object_mut)
{
data.insert(
CLEANUP_DURATION_MS_DATA_KEY.to_owned(),
serde_json::json!(started.elapsed().as_secs_f64() * 1000.0),
);
}
result
}
fn mark_cleanup_unverified(mut error: McpError) -> McpError {
let prior_data = error.data.take();
error.data = Some(serde_json::json!({
CLEANUP_UNVERIFIED_DATA_KEY: true,
"causeData": prior_data,
}));
error
}
/// Returns whether a connection error includes an unverified subprocess
/// cleanup outcome.
///
/// Callers that report lifecycle phases separately can use this marker to
/// avoid presenting an initialization failure as though process cleanup was
/// known to have succeeded.
#[must_use]
pub fn is_cleanup_unverified(error: &McpError) -> bool {
error
.data
.as_ref()
.and_then(serde_json::Value::as_object)
.and_then(|data| data.get(CLEANUP_UNVERIFIED_DATA_KEY))
.and_then(serde_json::Value::as_bool)
== Some(true)
}
pub(crate) fn resolve_stdio_command(
command: &str,
working_dir: Option<&Path>,
) -> McpResult<PathBuf> {
let command_path = Path::new(command);
if command_path.is_absolute() || command_path.components().count() <= 1 {
return Ok(command_path.to_path_buf());
}
let process_dir = std::env::current_dir().map_err(|error| {
McpError::internal_error(format!("Failed to resolve current directory: {error}"))
})?;
let base = match working_dir {
Some(path) if path.is_absolute() => path.to_path_buf(),
Some(path) => process_dir.join(path),
None => process_dir,
};
Ok(base.join(command_path))
}
/// Owns a subprocess until it is transferred into a [`Client`].
///
/// `std::process::Child` does not terminate or reap a still-running process on
/// drop. Keeping this guard armed across pipe extraction and the initialize
/// handshake prevents failed connection attempts from leaking child processes.
/// Explicit cleanup reports failures; Drop makes one final best-effort attempt
/// but cannot return an error or detach an unstructured cleanup worker.
pub(crate) struct ChildGuard {
child: Option<Child>,
ownership: ChildOwnership,
group_anchor: Option<ProcessGroupAnchor>,
}
impl ChildGuard {
pub(crate) fn new(child: Child) -> Self {
Self::with_ownership(child, ChildOwnership::DirectChild)
}
pub(crate) fn with_ownership(child: Child, ownership: ChildOwnership) -> Self {
Self {
child: Some(child),
ownership,
group_anchor: None,
}
}
pub(crate) fn with_process_group(child: Child, anchor: ProcessGroupAnchor) -> Self {
Self {
child: Some(child),
ownership: ChildOwnership::OwnedProcessGroup,
group_anchor: Some(anchor),
}
}
pub(crate) fn child_mut(&mut self) -> &mut Child {
self.child.as_mut().expect("ChildGuard already disarmed")
}
pub(crate) fn verify_group_anchor(&mut self) -> McpResult<()> {
self.group_anchor
.as_mut()
.map_or(Ok(()), ProcessGroupAnchor::verify_live)
}
pub(crate) fn disarm(mut self) -> Child {
debug_assert!(self.group_anchor.is_none());
self.child.take().expect("ChildGuard already disarmed")
}
pub(crate) fn disarm_all(mut self) -> (Child, Option<ProcessGroupAnchor>) {
(
self.child.take().expect("ChildGuard already disarmed"),
self.group_anchor.take(),
)
}
fn try_cleanup(&mut self) -> McpResult<()> {
let result = match self.child.as_mut() {
Some(child) => stop_child(child, self.ownership, &mut self.group_anchor),
None => self
.group_anchor
.as_mut()
.map_or(Ok(()), ProcessGroupAnchor::cleanup),
};
if result.is_ok() {
self.child = None;
self.group_anchor = None;
}
result
}
pub(crate) fn cleanup(mut self) -> McpResult<()> {
self.try_cleanup()
}
}
impl Drop for ChildGuard {
fn drop(&mut self) {
if let Err(error) = self.try_cleanup() {
log::error!("Subprocess cleanup was not verified during guard drop: {error}");
}
}
}
#[derive(Debug)]
struct ClientProgressParams {
marker: ProgressMarker,
progress: f64,
total: Option<f64>,
message: Option<String>,
meta: Option<serde_json::Map<String, serde_json::Value>>,
}
impl<'de> serde::Deserialize<'de> for ClientProgressParams {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Wire {
#[serde(rename = "progressTo\x6ben")]
marker: serde_json::Value,
progress: f64,
total: Option<f64>,
message: Option<String>,
#[serde(rename = "_meta")]
meta: Option<serde_json::Map<String, serde_json::Value>>,
}
let wire = <Wire as serde::Deserialize>::deserialize(deserializer)?;
let marker = match wire.marker {
serde_json::Value::String(marker) => ProgressMarker::String(marker),
serde_json::Value::Number(marker) => ProgressMarker::Number(
JsonInteger::try_from_number(marker).map_err(serde::de::Error::custom)?,
),
_ => {
return Err(serde::de::Error::custom(
"progressToken must be a string or mathematical integer",
));
}
};
Ok(Self {
marker,
progress: wire.progress,
total: wire.total,
message: wire.message,
meta: wire.meta,
})
}
}
impl ClientProgressParams {
fn is_semantically_valid_after(&self, previous: Option<f64>) -> bool {
self.progress.is_finite()
&& self.total.is_none_or(f64::is_finite)
&& previous.is_none_or(|previous| self.progress > previous)
}
}
fn parse_valid_client_progress(
params: &serde_json::Value,
previous: Option<f64>,
) -> Option<ClientProgressParams> {
let object = params.as_object()?;
// Optional protocol members are absent or typed; explicit null is not an
// alternate spelling for omission and must not acquire timer authority.
if object.get("total").is_some_and(serde_json::Value::is_null)
|| object
.get("message")
.is_some_and(serde_json::Value::is_null)
|| object.get("_meta").is_some_and(serde_json::Value::is_null)
{
return None;
}
let progress = serde_json::from_value::<ClientProgressParams>(params.clone()).ok()?;
progress
.is_semantically_valid_after(previous)
.then_some(progress)
}
fn method_not_found_response(request: &JsonRpcRequest) -> Option<JsonRpcMessage> {
let id = request.id.clone()?;
let error = McpError::method_not_found(&request.method);
let response = JsonRpcResponse::error(Some(id), error.into());
Some(JsonRpcMessage::Response(response))
}
fn invalid_notification_request_response(request: &JsonRpcRequest) -> Option<JsonRpcMessage> {
let id = request.id.clone()?;
let error = McpError::invalid_request(format!(
"Notification-only method {:?} must not include an ID",
request.method
));
let response = JsonRpcResponse::error(Some(id), error.into());
Some(JsonRpcMessage::Response(response))
}
fn server_request_response(request: &JsonRpcRequest) -> Option<JsonRpcMessage> {
request.id.as_ref()?;
if request.method.starts_with("notifications/") {
return invalid_notification_request_response(request);
}
method_not_found_response(request)
}
fn reverse_request_response<T>(request_id: RequestId, result: McpResult<T>) -> JsonRpcMessage
where
T: serde::Serialize,
{
match result.and_then(|result| {
serde_json::to_value(result)
.map_err(|_| McpError::internal_error("Failed to serialize reverse request result"))
}) {
Ok(result) => JsonRpcMessage::Response(JsonRpcResponse::success(request_id, result)),
Err(error) => {
JsonRpcMessage::Response(JsonRpcResponse::error(Some(request_id), error.into()))
}
}
}
fn decode_reverse_request_params<T>(request: &JsonRpcRequest) -> McpResult<T>
where
T: serde::de::DeserializeOwned,
{
let params = request
.params
.clone()
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
serde_json::from_value(params)
.map_err(|_| McpError::invalid_params("Invalid reverse request parameters"))
}
fn invoke_reverse_request_handler<P, R>(
cx: &Cx,
handler: &(
dyn for<'callback> Fn(
&'callback Cx,
ReverseRequestCancellation,
P,
) -> ReverseRequestFuture<'callback, R>
+ Send
+ Sync
),
cancellation: ReverseRequestCancellation,
params: P,
) -> McpResult<R> {
cancellation.checkpoint()?;
catch_client_callback_unwind(|| block_on(handler(cx, cancellation, params)))
.map_err(|_| McpError::internal_error("Client reverse request handler failed"))?
}
fn invoke_locked_reverse_request_handler<P, R>(
cx: &Cx,
handler: &Arc<
dyn for<'callback> Fn(
&'callback Cx,
ReverseRequestCancellation,
P,
) -> ReverseRequestFuture<'callback, R>
+ Send
+ Sync,
>,
cancellation: ReverseRequestCancellation,
params: P,
) -> McpResult<R> {
invoke_reverse_request_handler(cx, handler.as_ref(), cancellation, params)
}
const MAX_REVERSE_CALLBACK_WORKERS: usize = 4;
const MAX_QUEUED_REVERSE_CALLBACKS: usize = 16;
const REVERSE_CALLBACK_POLL_SLICE: Duration = Duration::from_millis(10);
const REVERSE_CALLBACK_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(250);
const REVERSE_CALLBACK_SHUTDOWN_POLLS: usize = 25;
const REVERSE_CALLBACK_SHUTDOWN_TIMEOUT_ERROR: &str =
"Client reverse callback workers did not stop within the shutdown bound";
struct ActiveReverseCallback {
request_id: RequestId,
cancellation: ReverseRequestCancellation,
}
/// Transport-neutral ownership and cancellation registry for exact-2024
/// server-to-client callbacks.
///
/// Each transport owns its dispatch and response-write mechanics, but all of
/// them must share these admission and response-vs-cancellation rules.
#[derive(Default)]
pub(crate) struct ReverseCallbackState {
closing: AtomicBool,
active: Mutex<Vec<ActiveReverseCallback>>,
terminal_error: Mutex<Option<McpError>>,
}
impl ReverseCallbackState {
pub(crate) fn admit(&self, request_id: &RequestId) -> McpResult<ReverseRequestCancellation> {
if let Some(error) = self.terminal_error() {
return Err(error);
}
let mut active = self
.active
.lock()
.map_err(|_| McpError::internal_error("Client reverse callback registry failed"))?;
if self.closing.load(Ordering::Acquire) {
return Err(McpError::internal_error(
"Client reverse callback dispatcher is closed",
));
}
if active.len() >= MAX_QUEUED_REVERSE_CALLBACKS {
return Err(McpError::internal_error(
"Client reverse callback capacity exceeded",
));
}
if active
.iter()
.any(|callback| callback.request_id.correlates_with(request_id))
{
return Err(McpError::invalid_request(
"Duplicate live reverse callback request ID",
));
}
let cancellation = ReverseRequestCancellation::new();
active.push(ActiveReverseCallback {
request_id: request_id.clone(),
cancellation: cancellation.clone(),
});
Ok(cancellation)
}
pub(crate) fn cancel(&self, request_id: &RequestId) -> bool {
let mut active = self
.active
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(index) = active
.iter()
.position(|callback| callback.request_id.correlates_with(request_id))
else {
return false;
};
// Removing the entry and recording cancellation happen under the
// same election lock. A callback that has not claimed its response
// now fails the claim; one that already claimed it was removed before
// this cancellation can observe it, preserving the original winner.
active.remove(index).cancellation.cancel();
true
}
pub(crate) fn complete(&self, request_id: &RequestId) {
let mut active = self
.active
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
active.retain(|callback| !callback.request_id.correlates_with(request_id));
}
/// Claims the one response write while ordering it against a cancellation
/// already admitted by the sole receive loop.
///
/// The claim is deliberately made before a protocol-sized transport write
/// and releases this registry lock before that write can block. This keeps
/// cancellation reception independent of a full child-stdin pipe while
/// preserving the response-vs-cancellation linearization point.
pub(crate) fn claim_response_if_open(
&self,
request_id: &RequestId,
cancellation: &ReverseRequestCancellation,
) -> bool {
let mut active = self
.active
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if self.closing.load(Ordering::Acquire)
|| !active.iter().any(|callback| {
callback.request_id.correlates_with(request_id)
&& callback.cancellation.belongs_to_same_request(cancellation)
})
|| !cancellation.is_open()
{
return false;
}
cancellation.record_response_sent();
active.retain(|callback| !callback.request_id.correlates_with(request_id));
true
}
pub(crate) fn cancel_all(&self) {
let mut active = self
.active
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
self.closing.store(true, Ordering::Release);
for callback in active.drain(..) {
callback.cancellation.cancel();
}
}
pub(crate) fn fail_connection(&self, error: McpError) {
{
let mut terminal_error = self
.terminal_error
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if terminal_error.is_none() {
*terminal_error = Some(error);
}
}
self.cancel_all();
}
pub(crate) fn terminal_error(&self) -> Option<McpError> {
self.terminal_error
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
struct ReverseCallbackPool {
state: Arc<ReverseCallbackState>,
response_sender: Arc<Mutex<StdioSendHalf<ChildStdin>>>,
cx: Cx,
tasks: Mutex<Vec<(RequestId, asupersync::runtime::TaskHandle<()>)>>,
}
/// Performs the elected reverse response write.
///
/// Callback frames use the ordinary framed transport path, rather than the
/// 512-byte atomic control path reserved for cancellation controls. The
/// codec's normal bounded frame limit therefore applies to full MCP sampling
/// and roots results. A failed write is terminal because framing disposition
/// is no longer recoverable.
fn commit_reverse_callback_response(
state: &ReverseCallbackState,
request_id: &RequestId,
response_sender: &Arc<Mutex<StdioSendHalf<ChildStdin>>>,
cx: &Cx,
cancellation: &ReverseRequestCancellation,
response: &JsonRpcMessage,
) -> Result<bool, TransportError> {
let mut sender = response_sender.lock().map_err(|_| TransportError::Closed)?;
if !state.claim_response_if_open(request_id, cancellation) {
return Ok(false);
}
sender.send(cx, response)?;
Ok(true)
}
impl ReverseCallbackPool {
fn new(response_sender: Arc<Mutex<StdioSendHalf<ChildStdin>>>, cx: Cx) -> Self {
Self {
state: Arc::new(ReverseCallbackState::default()),
response_sender,
cx,
tasks: Mutex::new(Vec::new()),
}
}
fn dispatch<P, R>(
&self,
request_id: RequestId,
params: P,
handler: Arc<
dyn for<'callback> Fn(
&'callback Cx,
ReverseRequestCancellation,
P,
) -> ReverseRequestFuture<'callback, R>
+ Send
+ Sync,
>,
) -> McpResult<()>
where
P: Send + 'static,
R: serde::Serialize + Send + 'static,
{
self.reap_finished_tasks()?;
let mut tasks = self
.tasks
.lock()
.map_err(|_| McpError::internal_error("Client reverse callback registry failed"))?;
if tasks.len() >= MAX_QUEUED_REVERSE_CALLBACKS {
return Err(McpError::internal_error(
"Client reverse callback capacity exceeded",
));
}
let cancellation = self.state.admit(&request_id)?;
let invoke_cancellation = cancellation.clone();
let response_id = request_id.clone();
let callback_id = request_id.clone();
let state = Arc::clone(&self.state);
let response_sender = Arc::clone(&self.response_sender);
let task_cx = Cx::current().unwrap_or_else(|| self.cx.clone());
let task = match task_cx.spawn(move |callback_cx| async move {
// Admission transfers cancellation observation to the callback.
// Invoke it even when cancellation raced ahead of scheduling so
// the supplied token remains an observable input; response
// election below still prevents a cancelled callback from
// committing a late frame.
let result = handler(&callback_cx, invoke_cancellation, params).await;
let response = reverse_request_response(response_id, result);
match commit_reverse_callback_response(
&state,
&callback_id,
&response_sender,
&callback_cx,
&cancellation,
&response,
) {
Ok(_) => state.complete(&callback_id),
Err(error) => {
state.fail_connection(transport_error_to_mcp(error));
state.complete(&callback_id);
}
}
}) {
Ok(task) => task,
Err(_) => {
self.state.complete(&request_id);
return Err(McpError::internal_error(
"Client reverse callback dispatcher is unavailable",
));
}
};
tasks.push((request_id, task));
Ok(())
}
fn cancel(&self, request_id: &RequestId) -> bool {
let cancelled = self.state.cancel(request_id);
if cancelled {
let tasks = self
.tasks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for (task_id, task) in tasks.iter() {
if task_id.correlates_with(request_id) {
// The token closes the protocol response election; the
// task abort also cancels and wakes the callback's Cx so
// a handler parked in a cancel-aware await can settle.
task.abort();
}
}
}
cancelled
}
fn cancel_all(&self) {
self.state.cancel_all();
let tasks = self
.tasks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for (_, task) in tasks.iter() {
task.abort();
}
}
fn reap_finished_tasks(&self) -> McpResult<usize> {
let mut tasks = self
.tasks
.lock()
.map_err(|_| McpError::internal_error("Client reverse callback registry failed"))?;
let mut active = Vec::with_capacity(tasks.len());
let mut panicked = false;
for (request_id, mut task) in std::mem::take(&mut *tasks) {
match task.try_join() {
Ok(None) => active.push((request_id, task)),
Ok(Some(())) | Err(asupersync::runtime::JoinError::Cancelled(_)) => {}
Err(
asupersync::runtime::JoinError::Panicked(_)
| asupersync::runtime::JoinError::PolledAfterCompletion,
) => panicked = true,
}
}
let active_count = active.len();
*tasks = active;
if panicked {
return Err(McpError::internal_error(
"Client reverse callback task panicked",
));
}
Ok(active_count)
}
/// Cancels every retained callback, then observes each task's terminal
/// state until the public shutdown deadline. Non-cooperative
/// tasks remain in this pool for a later explicit `Client::close` retry;
/// they are never cleared or detached.
fn join_bounded(&self) -> McpResult<()> {
self.cancel_all();
let deadline = Instant::now()
.checked_add(REVERSE_CALLBACK_SHUTDOWN_TIMEOUT)
.unwrap_or_else(Instant::now);
loop {
if self.reap_finished_tasks()? == 0 {
return Ok(());
}
if Instant::now() >= deadline {
return Err(McpError::internal_error(
REVERSE_CALLBACK_SHUTDOWN_TIMEOUT_ERROR,
));
}
std::thread::sleep(REVERSE_CALLBACK_POLL_SLICE);
}
}
/// Drop cannot report a join result. It requests cancellation, then lets
/// the task owner region perform structural settlement.
fn abort_for_drop(&self) {
self.cancel_all();
}
}
impl Drop for ReverseCallbackPool {
fn drop(&mut self) {
self.abort_for_drop();
}
}
enum LiveServerRequestDispatch {
Immediate(JsonRpcMessage),
CallbackAdmitted,
}
fn live_server_request_dispatch(
selected_era: Option<ProtocolEra>,
handlers: &ReverseRequestHandlers,
callbacks: &ReverseCallbackPool,
request: &JsonRpcRequest,
) -> Option<LiveServerRequestDispatch> {
let id = request.id.clone()?;
if request.method.starts_with("notifications/") {
return invalid_notification_request_response(request)
.map(LiveServerRequestDispatch::Immediate);
}
if request.method == "ping" && selected_era == Some(ProtocolEra::Legacy2024) {
return Some(LiveServerRequestDispatch::Immediate(
JsonRpcMessage::Response(JsonRpcResponse::success(id, serde_json::json!({}))),
));
}
// Sampling and roots are the only server-to-client reverse requests in
// exact MCP 2024-11-05. A final session must not silently keep servicing
// either method merely because a caller configured legacy callbacks on the
// client object.
if selected_era != Some(ProtocolEra::Legacy2024)
&& matches!(
request.method.as_str(),
"sampling/createMessage" | "roots/list"
)
{
return method_not_found_response(request).map(LiveServerRequestDispatch::Immediate);
}
let dispatch = match request.method.as_str() {
"sampling/createMessage" => match handlers.sampling_create_message.as_ref() {
Some(handler) => match decode_reverse_request_params(request) {
Ok(params) => callbacks
.dispatch(id.clone(), params, Arc::clone(handler))
.map_or_else(
|error| {
LiveServerRequestDispatch::Immediate(reverse_request_response::<
CreateMessageResult,
>(
id, Err(error)
))
},
|()| LiveServerRequestDispatch::CallbackAdmitted,
),
Err(error) => {
LiveServerRequestDispatch::Immediate(reverse_request_response::<
CreateMessageResult,
>(id, Err(error)))
}
},
None => LiveServerRequestDispatch::Immediate(reverse_request_response::<
CreateMessageResult,
>(
id,
Err(McpError::method_not_found("sampling/createMessage")),
)),
},
"roots/list" => match handlers.roots_list.as_ref() {
Some(handler) => match decode_reverse_request_params(request) {
Ok(params) => callbacks
.dispatch(id.clone(), params, Arc::clone(handler))
.map_or_else(
|error| {
LiveServerRequestDispatch::Immediate(reverse_request_response::<
ListRootsResult,
>(
id, Err(error)
))
},
|()| LiveServerRequestDispatch::CallbackAdmitted,
),
Err(error) => {
LiveServerRequestDispatch::Immediate(
reverse_request_response::<ListRootsResult>(id, Err(error)),
)
}
},
None => {
LiveServerRequestDispatch::Immediate(reverse_request_response::<ListRootsResult>(
id,
Err(McpError::method_not_found("roots/list")),
))
}
},
_ => return method_not_found_response(request).map(LiveServerRequestDispatch::Immediate),
};
Some(dispatch)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ServerNotificationKind {
Progress,
LogMessage,
}
enum ModernServerNotification {
Progress(Box<FinalProgressNotificationParams>),
Retained,
}
fn raw_notification_params_from_frame(frame: &[u8]) -> McpResult<Option<String>> {
#[derive(serde::Deserialize)]
struct RawNotificationEnvelope {
#[serde(default)]
params: Option<Box<serde_json::value::RawValue>>,
}
serde_json::from_slice::<RawNotificationEnvelope>(frame)
.map(|envelope| envelope.params.map(|params| params.get().to_owned()))
.map_err(|_| McpError::invalid_request("Client could not retain raw notification params"))
}
fn decode_final_server_notification(
request: &JsonRpcRequest,
raw_params: Option<&str>,
) -> Result<ServerNotification, fastmcp_protocol::FinalNotificationError> {
match raw_params {
Some(raw_params) => ServerNotification::decode_with_raw_params(request, raw_params),
None => ServerNotification::decode(request),
}
}
fn server_notification_kind(request: &JsonRpcRequest) -> Option<ServerNotificationKind> {
if request.id.is_some() {
return None;
}
match request.method.as_str() {
"notifications/progress" => Some(ServerNotificationKind::Progress),
"notifications/message" => Some(ServerNotificationKind::LogMessage),
_ => None,
}
}
/// Maximum number of non-progress final server notifications retained for a
/// modern client session before the connection fails closed.
pub const MAX_QUEUED_FINAL_SERVER_NOTIFICATIONS: usize = 64;
/// Maximum cancelled WebSocket request correlations retained for a possible
/// late terminal response. This bounded owner set keeps a reused connection
/// aligned even when several callers cancel before any compliant or tardy peer
/// produces its suppressed terminal response.
const MAX_QUEUED_WEBSOCKET_CANCELLED_RESPONSE_KEYS: usize = 64;
// A cancelled caller must not be held hostage by a peer that stops consuming
// frames. This is deliberately a short, connection-owned commit boundary: the
// cancellation notification is either committed or the connection fails
// closed; no detached sender survives the caller's cancellation.
const WEBSOCKET_CANCELLATION_CONTROL_SEND_TIMEOUT_NANOS: u64 = 100_000_000;
/// Wake the one owned ingress reader often enough to observe a caller-owned
/// cancellation domain without waiting for a peer frame. Exact-2024 peers may
/// suppress a cancelled request's terminal JSON-RPC result forever.
const WEBSOCKET_CANCELLED_RECV_POLL_NANOS: u64 = 20_000_000;
const FINAL_SERVER_NOTIFICATION_QUEUE_OVERFLOW_ERROR: &str =
"Final server notification queue capacity exceeded";
const LEGACY_SERVER_NOTIFICATION_QUEUE_OVERFLOW_ERROR: &str =
"Exact 2024-11-05 server notification queue capacity exceeded";
fn is_final_server_notification_method(request: &JsonRpcRequest) -> bool {
final_2026_07_28_method(&request.method)
.is_some_and(|method| method.admits_notification_from(Final2026Peer::Server))
}
fn is_legacy_server_notification_method(request: &JsonRpcRequest) -> bool {
request.id.is_none()
&& matches!(
request.method.as_str(),
NOTIFICATIONS_CANCELLED
| NOTIFICATIONS_PROGRESS
| NOTIFICATIONS_MESSAGE
| NOTIFICATIONS_PROMPTS_LIST_CHANGED
| NOTIFICATIONS_RESOURCES_LIST_CHANGED
| NOTIFICATIONS_RESOURCES_UPDATED
| NOTIFICATIONS_TOOLS_LIST_CHANGED
)
}
fn final_log_message_sink_projection(message: &FinalLogMessageParams) -> LogMessageParams {
let level = match message.level {
LoggingLevel::Debug => LogLevel::Debug,
LoggingLevel::Info => LogLevel::Info,
LoggingLevel::Notice => LogLevel::Notice,
LoggingLevel::Warning => LogLevel::Warning,
LoggingLevel::Error => LogLevel::Error,
LoggingLevel::Critical => LogLevel::Critical,
LoggingLevel::Alert => LogLevel::Alert,
LoggingLevel::Emergency => LogLevel::Emergency,
};
LogMessageParams {
level,
logger: message.logger.clone(),
data: message.data.clone(),
}
}
const INITIALIZE_REQUEST_ID: i64 = 1;
fn validate_initialize_response_id(response: &JsonRpcResponse) -> McpResult<()> {
validate_response_envelope(response)?;
let expected = RequestId::Number(INITIALIZE_REQUEST_ID);
if response.id.as_ref() == Some(&expected) {
return Ok(());
}
Err(McpError::internal_error(INITIALIZE_RESPONSE_ID_ERROR))
}
fn validate_response_envelope(response: &JsonRpcResponse) -> McpResult<()> {
if response.jsonrpc.as_ref() != JSONRPC_VERSION {
return Err(McpError::invalid_request(INVALID_RESPONSE_ENVELOPE_ERROR));
}
match (response.result.is_some(), response.error.is_some()) {
(true, false) | (false, true) => Ok(()),
(true, true) | (false, false) => {
Err(McpError::invalid_request(INVALID_RESPONSE_ENVELOPE_ERROR))
}
}
}
fn validate_inbound_typed_message(message: &JsonRpcMessage) -> McpResult<()> {
message
.validate()
.map_err(|_| McpError::invalid_request("Server sent an invalid JSON-RPC message"))
}
fn json_rpc_error_to_mcp(error: JsonRpcError) -> McpError {
let peer_code = error.code;
let local_code = peer_code.as_i32();
let code = local_code
.map(McpErrorCode::from)
.unwrap_or(McpErrorCode::InternalError);
let data = if local_code.is_none_or(|local_code| peer_code.as_str() != local_code.to_string()) {
// `McpErrorCode` is deliberately an i32 surface. Keep an unbounded
// or noncanonical peer JSON-RPC integer as a local diagnostic rather
// than truncating it, normalizing its spelling, or silently
// manufacturing a different custom code. Preserve the peer's own
// error data as a distinct value even when it is not an object.
let mut diagnostic = serde_json::Map::new();
diagnostic.insert(
"jsonrpcErrorCode".to_owned(),
serde_json::Value::Number(peer_code.to_number()),
);
diagnostic.insert(
"jsonrpcErrorData".to_owned(),
error.data.unwrap_or(serde_json::Value::Null),
);
Some(serde_json::Value::Object(diagnostic))
} else {
error.data
};
match data {
Some(data) => McpError::with_data(code, error.message, data),
None => McpError::new(code, error.message),
}
}
fn decode_response_payload<R: serde::de::DeserializeOwned>(
value: serde_json::Value,
) -> McpResult<R> {
serde_json::from_value(value)
.map_err(|_| McpError::internal_error(INVALID_RESPONSE_PAYLOAD_ERROR))
}
/// Reports a final field that cannot be preserved by a legacy convenience
/// projection.
fn final_projection_error(field: &str) -> McpError {
McpError::invalid_request(format!(
"Final {field} cannot be represented by the legacy convenience API"
))
}
fn ensure_absent_final_field<T>(field: &str, value: Option<T>) -> McpResult<()> {
if value.is_some() {
return Err(final_projection_error(field));
}
Ok(())
}
fn ensure_empty_final_fields(
field: &str,
values: &std::collections::BTreeMap<String, serde_json::Value>,
) -> McpResult<()> {
if !values.is_empty() {
return Err(final_projection_error(field));
}
Ok(())
}
/// Final catalog cache hints with an immediate, private lifetime are
/// observationally equivalent to the absence of cache hints in the legacy
/// API. Any reusable or shared cache directive would otherwise be lost.
fn ensure_legacy_cache_projection(
ttl_ms: &fastmcp_protocol::CacheTtl,
cache_scope: fastmcp_protocol::CacheScope,
) -> McpResult<()> {
if ttl_ms.try_as_millis() == Ok(0) && cache_scope == fastmcp_protocol::CacheScope::Private {
return Ok(());
}
Err(final_projection_error("cache fields"))
}
/// A final icon collection, including its theme and per-icon metadata, has no
/// exact counterpart in the legacy one-icon shape.
fn final_icons_to_legacy(icons: Option<Vec<RawIcon>>) -> McpResult<Option<fastmcp_protocol::Icon>> {
ensure_absent_final_field("catalog icons", icons)?;
Ok(None)
}
fn final_tool_to_legacy(tool: fastmcp_protocol::FinalTool) -> McpResult<Tool> {
ensure_absent_final_field("catalog title", tool.title)?;
ensure_absent_final_field("catalog metadata", tool.meta)?;
let icon = final_icons_to_legacy(tool.icons)?;
let annotations = match tool.annotations {
Some(annotations) => {
ensure_absent_final_field("catalog annotation title", annotations.title)?;
Some(ToolAnnotations {
destructive: annotations.destructive,
idempotent: annotations.idempotent,
read_only: annotations.read_only,
open_world_hint: annotations.open_world_hint,
})
}
None => None,
};
Ok(Tool {
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
output_schema: tool.output_schema,
icon,
version: None,
tags: Vec::new(),
annotations,
})
}
fn final_resource_to_legacy(resource: fastmcp_protocol::FinalResource) -> McpResult<Resource> {
ensure_absent_final_field("catalog title", resource.title)?;
ensure_absent_final_field("catalog annotations", resource.annotations)?;
ensure_absent_final_field("catalog size", resource.size)?;
ensure_absent_final_field("catalog metadata", resource.meta)?;
let icon = final_icons_to_legacy(resource.icons)?;
Ok(Resource {
uri: resource.uri.as_str().to_owned(),
name: resource.name,
description: resource.description,
mime_type: resource.mime_type,
icon,
version: None,
tags: Vec::new(),
})
}
fn final_resource_template_to_legacy(
template: fastmcp_protocol::FinalResourceTemplate,
) -> McpResult<ResourceTemplate> {
ensure_absent_final_field("catalog title", template.title)?;
ensure_absent_final_field("catalog annotations", template.annotations)?;
ensure_absent_final_field("catalog metadata", template.meta)?;
let icon = final_icons_to_legacy(template.icons)?;
Ok(ResourceTemplate {
uri_template: template.uri_template,
name: template.name,
description: template.description,
mime_type: template.mime_type,
icon,
version: None,
tags: Vec::new(),
})
}
fn final_prompt_to_legacy(prompt: fastmcp_protocol::FinalPrompt) -> McpResult<Prompt> {
ensure_absent_final_field("catalog title", prompt.title)?;
ensure_absent_final_field("catalog metadata", prompt.meta)?;
let icon = final_icons_to_legacy(prompt.icons)?;
let arguments = prompt
.arguments
.unwrap_or_default()
.into_iter()
.map(|argument| {
ensure_absent_final_field("prompt argument title", argument.title)?;
let required = argument
.required
.ok_or_else(|| final_projection_error("prompt argument required state"))?;
Ok(PromptArgument {
name: argument.name,
description: argument.description,
required,
})
})
.collect::<McpResult<Vec<_>>>()?;
Ok(Prompt {
name: prompt.name,
description: prompt.description,
arguments,
icon,
version: None,
tags: Vec::new(),
})
}
/// Re-homes final open fields in the exact legacy shape.
///
/// Legacy content has no typed metadata member: its schema permits `_meta` as
/// one of the flattened open members. Preserve it under that original wire
/// name, while rejecting manually-constructed open members that would shadow
/// a declared legacy field.
fn final_open_fields_to_legacy(
field: &str,
meta: Option<OpenMetadata>,
mut additional: std::collections::BTreeMap<String, serde_json::Value>,
declared_members: &[&str],
) -> McpResult<std::collections::BTreeMap<String, serde_json::Value>> {
if additional
.keys()
.any(|key| key == "_meta" || declared_members.contains(&key.as_str()))
{
return Err(final_projection_error(field));
}
let Some(meta) = meta else {
return Ok(additional);
};
let encoded_meta =
serde_json::to_value(meta).map_err(|_| final_projection_error("metadata serialization"))?;
additional.insert("_meta".to_owned(), encoded_meta);
Ok(additional)
}
fn final_resource_content_to_legacy(
resource: EmbeddedResourceContents,
) -> McpResult<LegacyResourceContent> {
match resource {
EmbeddedResourceContents::Text {
uri,
text,
mime_type,
meta,
additional,
} => Ok(LegacyResourceContent::Text {
uri: uri.as_str().to_owned(),
mime_type,
text,
additional: final_open_fields_to_legacy(
"conflicting resource field",
meta,
additional,
&["uri", "text", "mimeType"],
)?,
}),
EmbeddedResourceContents::Blob {
uri,
blob,
mime_type,
meta,
additional,
} => Ok(LegacyResourceContent::Blob {
uri: uri.as_str().to_owned(),
mime_type,
blob,
additional: final_open_fields_to_legacy(
"conflicting resource field",
meta,
additional,
&["uri", "blob", "mimeType"],
)?,
}),
}
}
fn final_content_to_legacy(content: ContentBlock) -> McpResult<LegacyContent> {
match content {
ContentBlock::Text {
text,
annotations,
meta,
additional,
} => Ok(LegacyContent::Text {
text,
annotations,
additional: final_open_fields_to_legacy(
"conflicting content field",
meta,
additional,
&["type", "text", "annotations"],
)?,
}),
ContentBlock::Image {
data,
mime_type,
annotations,
meta,
additional,
} => Ok(LegacyContent::Image {
data,
mime_type,
annotations,
additional: final_open_fields_to_legacy(
"conflicting content field",
meta,
additional,
&["type", "data", "mimeType", "annotations"],
)?,
}),
ContentBlock::Audio { .. } => Err(final_projection_error("audio content")),
ContentBlock::Resource {
resource,
annotations,
meta,
additional,
} => Ok(LegacyContent::Resource {
resource: final_resource_content_to_legacy(resource)?,
annotations,
additional: final_open_fields_to_legacy(
"conflicting content field",
meta,
additional,
&["type", "resource", "annotations"],
)?,
}),
ContentBlock::ResourceLink { .. } => Err(final_projection_error("resource_link content")),
}
}
fn unexpected_convenience_result(method: &str) -> McpError {
McpError::invalid_request(format!(
"Negotiated core result was not a {method} result for the convenience API"
))
}
fn list_catalog_semantic_parameters(
include_tags: Option<&Vec<String>>,
exclude_tags: Option<&Vec<String>>,
) -> serde_json::Value {
let mut members = serde_json::Map::new();
if let Some(include_tags) = include_tags {
members.insert("includeTags".to_owned(), serde_json::json!(include_tags));
}
if let Some(exclude_tags) = exclude_tags {
members.insert("excludeTags".to_owned(), serde_json::json!(exclude_tags));
}
serde_json::Value::Object(members)
}
fn list_catalog_wire_parameters(
cursor: Option<&str>,
include_tags: Option<&Vec<String>>,
exclude_tags: Option<&Vec<String>>,
) -> serde_json::Value {
let mut members = serde_json::Map::new();
if let Some(cursor) = cursor {
members.insert("cursor".to_owned(), serde_json::json!(cursor));
}
if let Some(include_tags) = include_tags {
members.insert("includeTags".to_owned(), serde_json::json!(include_tags));
}
if let Some(exclude_tags) = exclude_tags {
members.insert("excludeTags".to_owned(), serde_json::json!(exclude_tags));
}
serde_json::Value::Object(members)
}
fn list_tools_semantic_parameters(params: &ListToolsParams) -> serde_json::Value {
list_catalog_semantic_parameters(params.include_tags.as_ref(), params.exclude_tags.as_ref())
}
fn list_resources_semantic_parameters(params: &ListResourcesParams) -> serde_json::Value {
list_catalog_semantic_parameters(params.include_tags.as_ref(), params.exclude_tags.as_ref())
}
fn list_prompts_semantic_parameters(params: &ListPromptsParams) -> serde_json::Value {
list_catalog_semantic_parameters(params.include_tags.as_ref(), params.exclude_tags.as_ref())
}
fn list_resource_templates_semantic_parameters(
params: &ListResourceTemplatesParams,
) -> serde_json::Value {
list_catalog_semantic_parameters(params.include_tags.as_ref(), params.exclude_tags.as_ref())
}
fn convenience_tools_page(result: CoreResult) -> McpResult<(Vec<Tool>, Option<String>)> {
match result {
CoreResult::Legacy(LegacyCoreResult::ToolsList(result)) => {
Ok((result.tools, result.next_cursor))
}
CoreResult::Final(FinalCoreResult::ToolsList { result, .. }) => {
let fastmcp_protocol::FinalListToolsResult {
tools,
next_cursor,
ttl_ms,
cache_scope,
} = result.payload;
ensure_legacy_cache_projection(&ttl_ms, cache_scope)?;
Ok((
tools
.into_iter()
.map(final_tool_to_legacy)
.collect::<McpResult<Vec<_>>>()?,
next_cursor,
))
}
_ => Err(unexpected_convenience_result("tools/list")),
}
}
fn convenience_resources_page(result: CoreResult) -> McpResult<(Vec<Resource>, Option<String>)> {
match result {
CoreResult::Legacy(LegacyCoreResult::ResourcesList(result)) => {
Ok((result.resources, result.next_cursor))
}
CoreResult::Final(FinalCoreResult::ResourcesList { result, .. }) => {
let fastmcp_protocol::FinalListResourcesResult {
resources,
next_cursor,
ttl_ms,
cache_scope,
} = result.payload;
ensure_legacy_cache_projection(&ttl_ms, cache_scope)?;
Ok((
resources
.into_iter()
.map(final_resource_to_legacy)
.collect::<McpResult<Vec<_>>>()?,
next_cursor,
))
}
_ => Err(unexpected_convenience_result("resources/list")),
}
}
fn convenience_resource_templates_page(
result: CoreResult,
) -> McpResult<(Vec<ResourceTemplate>, Option<String>)> {
match result {
CoreResult::Legacy(LegacyCoreResult::ResourceTemplatesList(result)) => {
Ok((result.resource_templates, result.next_cursor))
}
CoreResult::Final(FinalCoreResult::ResourceTemplatesList { result, .. }) => {
let fastmcp_protocol::FinalListResourceTemplatesResult {
resource_templates,
next_cursor,
ttl_ms,
cache_scope,
} = result.payload;
ensure_legacy_cache_projection(&ttl_ms, cache_scope)?;
Ok((
resource_templates
.into_iter()
.map(final_resource_template_to_legacy)
.collect::<McpResult<Vec<_>>>()?,
next_cursor,
))
}
_ => Err(unexpected_convenience_result("resources/templates/list")),
}
}
fn convenience_prompts_page(result: CoreResult) -> McpResult<(Vec<Prompt>, Option<String>)> {
match result {
CoreResult::Legacy(LegacyCoreResult::PromptsList(result)) => {
Ok((result.prompts, result.next_cursor))
}
CoreResult::Final(FinalCoreResult::PromptsList { result, .. }) => {
let fastmcp_protocol::FinalListPromptsResult {
prompts,
next_cursor,
ttl_ms,
cache_scope,
} = result.payload;
ensure_legacy_cache_projection(&ttl_ms, cache_scope)?;
Ok((
prompts
.into_iter()
.map(final_prompt_to_legacy)
.collect::<McpResult<Vec<_>>>()?,
next_cursor,
))
}
_ => Err(unexpected_convenience_result("prompts/list")),
}
}
fn convenience_tool_call(result: CoreResult) -> McpResult<CallToolResult> {
match result {
CoreResult::Legacy(LegacyCoreResult::ToolsCall(result)) => Ok(result),
CoreResult::Final(FinalCoreResult::ToolsCall { result, .. }) => {
let fastmcp_protocol::FinalCallToolResult {
content,
is_error,
structured_content,
} = result.payload;
ensure_absent_final_field("structuredContent", structured_content)?;
Ok(CallToolResult {
content: content
.into_iter()
.map(final_content_to_legacy)
.collect::<McpResult<Vec<_>>>()?,
is_error,
meta: None,
additional: std::collections::BTreeMap::new(),
})
}
_ => Err(unexpected_convenience_result("tools/call")),
}
}
fn convenience_resource_read(result: CoreResult) -> McpResult<Vec<LegacyResourceContent>> {
match result {
CoreResult::Legacy(LegacyCoreResult::ResourcesRead(result)) => Ok(result.contents),
CoreResult::Final(FinalCoreResult::ResourcesRead { result, .. }) => {
let fastmcp_protocol::FinalReadResourceResult {
contents,
ttl_ms,
cache_scope,
} = result.payload;
ensure_legacy_cache_projection(&ttl_ms, cache_scope)?;
contents
.into_iter()
.map(final_resource_content_to_legacy)
.collect()
}
_ => Err(unexpected_convenience_result("resources/read")),
}
}
fn convenience_prompt_get(result: CoreResult) -> McpResult<Vec<LegacyPromptMessage>> {
match result {
CoreResult::Legacy(LegacyCoreResult::PromptsGet(result)) => Ok(result.messages),
CoreResult::Final(FinalCoreResult::PromptsGet { result, .. }) => {
let fastmcp_protocol::FinalGetPromptResult {
description,
messages,
} = result.payload;
ensure_absent_final_field("prompt description", description)?;
messages
.into_iter()
.map(|message| {
Ok(LegacyPromptMessage {
role: message.role,
content: final_content_to_legacy(message.content)?,
additional: std::collections::BTreeMap::new(),
})
})
.collect()
}
_ => Err(unexpected_convenience_result("prompts/get")),
}
}
fn validate_initialize_result(result: &InitializeResult) -> McpResult<()> {
if result.protocol_version == PROTOCOL_VERSION {
return Ok(());
}
Err(McpError::internal_error(UNSUPPORTED_PROTOCOL_VERSION_ERROR))
}
/// The only probe outcomes that may authorize one fresh exact-2024 child.
///
/// This is deliberately not derived from a flattened [`McpError`]: the
/// classifier retains whether `MethodNotFound` was correlated to the committed
/// first `server/discover` request, and whether a timeout occurred before any
/// child ingress was admitted.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum AutoStdioFallbackSignal {
/// The first, correlated discovery response was JSON-RPC MethodNotFound.
CorrelatedDiscoverMethodNotFound,
/// The committed first discovery request reached a clean receive deadline.
CleanFirstProbeTimeout { source: RequestTimeoutSource },
}
/// Rechecks the caller-owned context at Auto's one allowed downgrade boundary.
///
/// The modern probe owns its disposable child and may complete with one of the
/// narrow signals that authorizes exact-2024. Neither signal authorizes a new
/// subprocess after the caller has cancelled its connection operation.
pub(crate) fn admit_auto_legacy_fallback(cx: &Cx) -> McpResult<()> {
cx.checkpoint().map_err(|_| McpError::request_cancelled())
}
/// Refuses a protocol plan that selects a feature compiled out of this client.
///
/// Public constructors call this before resolving a subprocess, allocating a
/// request ID, spawning a receive task, or contacting an HTTP peer.
#[cfg_attr(feature = "legacy-2024-11-05", allow(clippy::unnecessary_wraps))]
pub(crate) fn validate_protocol_plan_feature(protocol_plan: &ClientProtocolPlan) -> McpResult<()> {
#[cfg(feature = "legacy-2024-11-05")]
let _ = protocol_plan;
#[cfg(not(feature = "legacy-2024-11-05"))]
if !matches!(protocol_plan.policy(), ProtocolPolicy::ModernOnly) {
return Err(McpError::invalid_params(format!(
"FeatureUnavailable: legacy-2024-11-05 is compiled out; policy {:?} requires --features legacy-2024-11-05",
protocol_plan.policy(),
)));
}
Ok(())
}
/// Legacy synchronous split transport retained only for internal regression tests.
///
/// This adapter is deliberately generic over the transport crate's client
/// halves. A WebSocket upgrader supplies those halves after it has completed
/// its own HTTP Upgrade boundary; this client owns them for the remainder of
/// the negotiated MCP connection. In particular, the receive half supplies
/// [`ReceivedTransportFrame`] values, so a successful result can retain the
/// peer's exact JSON `result` bytes rather than a reserialized approximation.
#[cfg(test)]
struct SynchronousWebSocketClientTransport<R, S> {
receiver: R,
sender: S,
}
#[cfg(test)]
impl<R, S> SynchronousWebSocketClientTransport<R, S> {
/// Joins independently-owned WebSocket ingress and egress halves.
#[must_use]
pub fn from_split(receiver: R, sender: S) -> Self {
Self { receiver, sender }
}
/// Returns the owned ingress and egress halves.
#[must_use]
pub fn into_split(self) -> (R, S) {
(self.receiver, self.sender)
}
}
#[cfg(test)]
impl<R, S> Transport for SynchronousWebSocketClientTransport<R, S>
where
R: ClientTransportRecvHalf,
S: TransportSendHalf,
{
fn send(&mut self, cx: &Cx, message: &JsonRpcMessage) -> Result<(), TransportError> {
self.sender.send(cx, message)
}
fn recv(&mut self, cx: &Cx) -> Result<JsonRpcMessage, TransportError> {
self.receiver.recv(cx)
}
fn close(&mut self) -> Result<(), TransportError> {
self.receiver.close()?;
self.sender.close()
}
}
#[cfg(test)]
fn receive_websocket_frame<R, S>(
transport: &mut SynchronousWebSocketClientTransport<R, S>,
cx: &Cx,
) -> Result<ReceivedTransportFrame, TransportError>
where
R: ClientTransportRecvHalf,
S: TransportSendHalf,
{
transport.receiver.recv_with_source(cx)
}
/// A negotiated, full-duplex MCP WebSocket client.
///
/// Construct this with [`ClientBuilder::connect_websocket_with_cx`] after an
/// application has completed the WebSocket Upgrade and split the transport.
/// The builder's immutable `Auto`, exact-2024, or final-2026 policy is
/// admitted before the first frame is sent. `Auto` sends final discovery once;
/// a correlated `MethodNotFound` reply is terminal and never replays exact
/// initialization on that connection.
///
/// Requests are multiplexed by [`RequestExecutor`]. Callers may issue many
/// [`Self::execute`] calls before waiting; a wait routes out-of-order results,
/// progress, cancellation notifications, and exact-2024 reverse requests to
/// their respective owners. Reverse requests are intentionally exposed as
/// capability-bearing [`ReverseRequest`] values so the embedding application
/// can choose its response policy and cannot answer a cancelled or stale
/// request.
#[cfg(test)]
struct SynchronousWebSocketClient<R, S>
where
R: ClientTransportRecvHalf,
S: TransportSendHalf,
{
cx: Cx,
session: ClientSession,
executor: RequestExecutor<SynchronousWebSocketClientTransport<R, S>>,
next_id: AtomicU64,
}
#[cfg(test)]
impl<R, S> std::fmt::Debug for SynchronousWebSocketClient<R, S>
where
R: ClientTransportRecvHalf,
S: TransportSendHalf,
{
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("SynchronousWebSocketClient")
.field("selected_era", &self.session.selected_era())
.field("protocol_plan", self.session.protocol_plan())
.finish_non_exhaustive()
}
}
#[cfg(test)]
impl<R, S> SynchronousWebSocketClient<R, S>
where
R: ClientTransportRecvHalf,
S: TransportSendHalf,
{
/// Negotiates a supplied upgraded and split WebSocket transport.
///
/// The supplied `Cx` becomes this connection's sole owned cancellation
/// domain. All handshake, request, cancellation, reverse-response, and
/// shutdown I/O uses that context; request callers cannot substitute an
/// unrelated runtime context part-way through the connection.
pub fn connect_with_cx(
cx: Cx,
protocol_plan: ClientProtocolPlan,
client_info: ClientInfo,
client_capabilities: ClientCapabilities,
receiver: R,
sender: S,
) -> McpResult<Self> {
validate_protocol_plan_feature(&protocol_plan)?;
cx.checkpoint().map_err(|_| McpError::request_cancelled())?;
let mut transport = SynchronousWebSocketClientTransport::from_split(receiver, sender);
let initialization = (match protocol_plan.policy() {
ProtocolPolicy::ModernOnly => {
websocket_discover(&mut transport, &cx, &client_info, &client_capabilities, 1)
}
ProtocolPolicy::LegacyOnly => {
websocket_initialize(&mut transport, &cx, &client_info, &client_capabilities, 1)
.map(WebSocketInitialization::Legacy)
}
ProtocolPolicy::Auto => {
websocket_discover(&mut transport, &cx, &client_info, &client_capabilities, 1)
}
})
.map_err(|error| match error {
WebSocketHandshakeError::MethodNotFound => McpError::invalid_request(
"WebSocket handshake method was not supported by the peer",
),
WebSocketHandshakeError::Mcp(error) => error,
})?;
if matches!(initialization, WebSocketInitialization::Legacy(_)) {
transport
.send(
&cx,
&JsonRpcMessage::Request(JsonRpcRequest::initialized_notification()),
)
.map_err(transport_error_to_mcp)?;
}
let session = match initialization {
WebSocketInitialization::Legacy(result) => ClientSession::try_new(
client_info,
client_capabilities,
result.server_info,
result.capabilities,
result.protocol_version,
)
.map_err(|_| McpError::internal_error(UNSUPPORTED_PROTOCOL_VERSION_ERROR))?
.with_legacy_instructions(result.instructions),
WebSocketInitialization::Modern {
server_info,
discovery,
} => ClientSession::try_new(
client_info,
client_capabilities,
server_info,
ServerCapabilities::default(),
MODERN_PROTOCOL_VERSION.to_owned(),
)
.map_err(|_| McpError::internal_error(UNSUPPORTED_PROTOCOL_VERSION_ERROR))?
.with_server_discovery(discovery),
}
.try_with_protocol_plan(protocol_plan)
.map_err(|_| {
McpError::internal_error("Configured protocol policy rejects the negotiated era")
})?;
let peer_era = session.selected_era().ok_or_else(|| {
McpError::internal_error("WebSocket negotiation completed without selecting an era")
})?;
let executor = RequestExecutor::with_source_frame_receiver(
transport,
peer_era.into(),
Some(receive_websocket_frame::<R, S>),
);
Ok(Self {
cx,
session,
executor,
// Handshake consumes ID 1, and an Auto legacy fallback consumes
// ID 2. Starting ordinary IDs at 3 leaves both paths collision-free.
next_id: AtomicU64::new(3),
})
}
/// Returns the immutable session established by discovery or initialize.
#[must_use]
pub const fn session(&self) -> &ClientSession {
&self.session
}
/// Returns the exact protocol era frozen by the completed handshake.
#[must_use]
pub const fn selected_protocol_era(&self) -> ProtocolEra {
self.session
.selected_era()
.expect("negotiated WebSocket clients always select an era")
}
/// Starts one request without waiting for any other outstanding request.
pub fn execute(
&self,
method: impl Into<String>,
params: Option<serde_json::Value>,
) -> McpResult<RequestExecution<SynchronousWebSocketClientTransport<R, S>>> {
let id = self
.next_id
.try_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
current.checked_add(1)
})
.map_err(|_| McpError::internal_error("WebSocket client request ID space exhausted"))?;
let id = i64::try_from(id)
.map_err(|_| McpError::internal_error("WebSocket client request ID exceeds i64"))?;
let params = self.decorate_request_parameters(params)?;
self.executor
.execute(&self.cx, JsonRpcRequest::new(method.into(), params, id))
}
/// Waits for one request result while routing all peer traffic on this connection.
pub fn wait(
&self,
execution: &mut RequestExecution<SynchronousWebSocketClientTransport<R, S>>,
) -> McpResult<JsonRpcResponse> {
self.executor.wait(&self.cx, execution)
}
/// Waits for one response and returns its exact admitted `result` source.
///
/// The source is `None` only for JSON-RPC error envelopes. It is never
/// reconstructed from a decoded `serde_json::Value`.
pub fn wait_with_raw_result(
&self,
execution: &mut RequestExecution<SynchronousWebSocketClientTransport<R, S>>,
) -> McpResult<(JsonRpcResponse, Option<String>)> {
self.executor.wait_with_raw_result(&self.cx, execution)
}
/// Takes a response that was already routed by [`Self::drive`].
pub fn try_take_response_with_raw_result(
&self,
execution: &mut RequestExecution<SynchronousWebSocketClientTransport<R, S>>,
) -> McpResult<Option<(JsonRpcResponse, Option<String>)>> {
self.executor.try_take_response_with_raw_result(execution)
}
/// Drives one peer frame without issuing another request.
pub fn drive(&self) -> McpResult<()> {
self.executor.drive(&self.cx)
}
/// Cancels one locally-owned live execution over the selected wire era.
pub fn cancel(
&self,
execution: &mut RequestExecution<SynchronousWebSocketClientTransport<R, S>>,
) -> McpResult<()> {
self.executor.cancel(&self.cx, execution)
}
/// Returns exact-2024 reverse requests admitted while driving this connection.
pub fn take_reverse_requests(&self) -> Vec<ReverseRequest> {
self.executor.take_reverse_requests()
}
/// Responds to one currently live exact-2024 reverse request.
pub fn respond_to_reverse_request(
&self,
request: &ReverseRequest,
result: serde_json::Value,
) -> McpResult<()> {
self.executor
.respond_to_reverse_request(&self.cx, request, result)
}
/// Returns connection-level notifications that did not belong to a request stream.
pub fn take_notifications(&self) -> Vec<JsonRpcRequest> {
self.executor.take_notifications()
}
/// Closes the WebSocket halves after cancelling every live request owner.
pub fn close(&self) -> McpResult<()> {
self.executor.shutdown(&self.cx)
}
fn decorate_request_parameters(
&self,
params: Option<serde_json::Value>,
) -> McpResult<Option<serde_json::Value>> {
if self.selected_protocol_era() != ProtocolEra::Modern2026 {
return Ok(params);
}
let mut params = params.unwrap_or_else(|| serde_json::json!({}));
let object = params.as_object_mut().ok_or_else(|| {
McpError::invalid_params("Modern WebSocket requests require object parameters")
})?;
let mut metadata = serde_json::to_value(FinalRequestMeta {
protocol_version: MODERN_PROTOCOL_VERSION.to_owned(),
client_capabilities: self.session.client_capabilities().clone(),
client_info: Some(self.session.modern_client_implementation()),
additional_metadata: BTreeMap::new(),
})
.map_err(|_| McpError::internal_error("Modern WebSocket request metadata is invalid"))?;
if let Some(existing) = object.remove("_meta") {
let existing = existing.as_object().ok_or_else(|| {
McpError::invalid_params("Modern WebSocket request metadata must be an object")
})?;
let generated = metadata.as_object_mut().ok_or_else(|| {
McpError::internal_error("Modern WebSocket request metadata is not an object")
})?;
for (key, value) in existing {
generated
.entry(key.clone())
.or_insert_with(|| value.clone());
}
}
object.insert("_meta".to_owned(), metadata);
Ok(Some(params))
}
}
#[cfg(any(test, feature = "websocket-experimental"))]
// Held protocol-native for one short negotiation window per client; boxing
// would touch every construction and match site for no allocation win.
#[allow(clippy::large_enum_variant)]
enum WebSocketInitialization {
Legacy(InitializeResult),
Modern {
server_info: ServerInfo,
discovery: ServerDiscoverResult,
},
}
#[cfg(any(test, feature = "websocket-experimental"))]
enum WebSocketHandshakeError {
MethodNotFound,
Mcp(McpError),
}
#[cfg(test)]
fn websocket_discover<R, S>(
transport: &mut SynchronousWebSocketClientTransport<R, S>,
cx: &Cx,
client_info: &ClientInfo,
client_capabilities: &ClientCapabilities,
id: i64,
) -> Result<WebSocketInitialization, WebSocketHandshakeError>
where
R: ClientTransportRecvHalf,
S: TransportSendHalf,
{
let mut params = serde_json::to_value(ServerDiscoverRequest::default()).map_err(|_| {
WebSocketHandshakeError::Mcp(McpError::internal_error(
"Modern WebSocket discovery parameters could not be serialized",
))
})?;
let metadata = params
.get_mut("_meta")
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
WebSocketHandshakeError::Mcp(McpError::internal_error(
"Modern WebSocket discovery metadata is missing",
))
})?;
metadata.insert(
FINAL_CLIENT_CAPABILITIES_META_KEY.to_owned(),
serde_json::to_value(client_capabilities).map_err(|_| {
WebSocketHandshakeError::Mcp(McpError::internal_error(
"Modern WebSocket client capabilities could not be serialized",
))
})?,
);
metadata.insert(
"io.modelcontextprotocol/clientInfo".to_owned(),
serde_json::to_value(client_info).map_err(|_| {
WebSocketHandshakeError::Mcp(McpError::internal_error(
"Modern WebSocket client identity could not be serialized",
))
})?,
);
let response = websocket_exchange(
transport,
cx,
JsonRpcRequest::new(SERVER_DISCOVER_METHOD, Some(params), id),
)?;
let source = response.raw_result.ok_or_else(|| {
WebSocketHandshakeError::Mcp(McpError::invalid_request(
"Modern WebSocket discovery response has no admitted result source",
))
})?;
let discovery = serde_json::from_str::<ServerDiscoverResult>(&source).map_err(|_| {
WebSocketHandshakeError::Mcp(McpError::invalid_request(
"Modern WebSocket discovery response has an invalid result",
))
})?;
if !discovery
.supported_versions()
.iter()
.any(|version| version == MODERN_PROTOCOL_VERSION)
{
return Err(WebSocketHandshakeError::Mcp(McpError::internal_error(
UNSUPPORTED_PROTOCOL_VERSION_ERROR,
)));
}
let server_info = discovery.server_info().cloned().ok_or_else(|| {
WebSocketHandshakeError::Mcp(McpError::invalid_request(
"Modern WebSocket discovery response has no server identity",
))
})?;
Ok(WebSocketInitialization::Modern {
server_info,
discovery,
})
}
#[cfg(test)]
fn websocket_initialize<R, S>(
transport: &mut SynchronousWebSocketClientTransport<R, S>,
cx: &Cx,
client_info: &ClientInfo,
client_capabilities: &ClientCapabilities,
id: i64,
) -> Result<InitializeResult, WebSocketHandshakeError>
where
R: ClientTransportRecvHalf,
S: TransportSendHalf,
{
let response = websocket_exchange(
transport,
cx,
JsonRpcRequest::new(
"initialize",
Some(
serde_json::to_value(InitializeParams {
protocol_version: PROTOCOL_VERSION.to_owned(),
capabilities: client_capabilities.clone(),
client_info: client_info.clone(),
})
.map_err(|_| {
WebSocketHandshakeError::Mcp(McpError::internal_error(
"WebSocket initialize parameters could not be serialized",
))
})?,
),
id,
),
)?;
let result = response.result.ok_or_else(|| {
WebSocketHandshakeError::Mcp(McpError::invalid_request(
"WebSocket initialize response has no result",
))
})?;
let result = serde_json::from_value::<InitializeResult>(result).map_err(|_| {
WebSocketHandshakeError::Mcp(McpError::invalid_request(
"WebSocket initialize response has an invalid result",
))
})?;
validate_initialize_result(&result).map_err(WebSocketHandshakeError::Mcp)?;
Ok(result)
}
#[cfg(any(test, feature = "websocket-experimental"))]
struct WebSocketHandshakeResponse {
result: Option<serde_json::Value>,
raw_result: Option<String>,
}
#[cfg(test)]
fn websocket_exchange<R, S>(
transport: &mut SynchronousWebSocketClientTransport<R, S>,
cx: &Cx,
request: JsonRpcRequest,
) -> Result<WebSocketHandshakeResponse, WebSocketHandshakeError>
where
R: ClientTransportRecvHalf,
S: TransportSendHalf,
{
let request_id = request.id.clone().ok_or_else(|| {
WebSocketHandshakeError::Mcp(McpError::internal_error(
"WebSocket handshake request requires an ID",
))
})?;
transport
.send(cx, &JsonRpcMessage::Request(request))
.map_err(|error| WebSocketHandshakeError::Mcp(transport_error_to_mcp(error)))?;
let frame = receive_websocket_frame(transport, cx)
.map_err(|error| WebSocketHandshakeError::Mcp(transport_error_to_mcp(error)))?;
let JsonRpcMessage::Response(response) = frame.message() else {
return Err(WebSocketHandshakeError::Mcp(McpError::invalid_request(
"WebSocket handshake received a non-response frame",
)));
};
if !response
.id
.as_ref()
.is_some_and(|response_id| response_id.correlates_with(&request_id))
{
return Err(WebSocketHandshakeError::Mcp(McpError::invalid_request(
"WebSocket handshake response ID does not match its request",
)));
}
if let Some(error) = response.error.clone() {
if error.code.as_i32() == Some(-32601) {
return Err(WebSocketHandshakeError::MethodNotFound);
}
return Err(WebSocketHandshakeError::Mcp(json_rpc_error_to_mcp(error)));
}
let response = response.clone();
let raw_result = raw_result_from_admitted_response(&response, frame, "WebSocket")
.map_err(WebSocketHandshakeError::Mcp)?;
Ok(WebSocketHandshakeResponse {
result: response.result,
raw_result,
})
}
/// One response received through the real async WebSocket client.
///
/// `raw_result` is populated only for successful JSON-RPC response envelopes.
/// It is extracted from the same bounded transport frame as `response`; it is
/// never reconstructed from the decoded `serde_json::Value`.
#[derive(Debug, Clone)]
#[cfg(feature = "websocket-experimental")]
pub struct WebSocketResponse {
/// The typed JSON-RPC response admitted from the peer.
pub response: JsonRpcResponse,
/// The exact peer-authored JSON source of the response `result` member.
pub raw_result: Option<String>,
}
/// Connection-owned exact-2024 callback workers for one split WebSocket.
///
/// The client retains the only receive half. Callback workers share only the
/// independently serialized write half, so an inbound cancellation can abort
/// and wake a parked callback without competing for frames.
#[cfg(feature = "websocket-experimental")]
struct WebSocketReverseCallbackPool<IO>
where
IO: AsyncRead + AsyncWrite + Unpin,
{
state: Arc<ReverseCallbackState>,
terminal: Arc<WebSocketReverseCallbackTerminal>,
response_sender: Arc<AsyncMutex<AsyncWsClientSendHalf<IO>>>,
cx: Cx,
tasks: Mutex<Vec<(RequestId, asupersync::runtime::TaskHandle<()>)>>,
}
/// One-reader terminal wakeup for retained WebSocket callbacks.
///
/// Exactly one request/listener owns ingress at a time, so one cancel-correct
/// oneshot waiter is sufficient. Publishing records the terminal error before
/// waking that owner, preventing a silent peer read from hiding callback
/// failure behind a later frame.
#[derive(Default)]
#[cfg(feature = "websocket-experimental")]
struct WebSocketReverseCallbackTerminal {
error: Mutex<Option<McpError>>,
waiter: Mutex<Option<oneshot::Sender<()>>>,
}
/// Polls a callback future behind a panic boundary. The boundary is applied
/// to every poll, so a panic after an await is published just as promptly as a
/// panic on the first poll; no reader-side reap is required to wake ingress.
#[cfg(feature = "websocket-experimental")]
async fn websocket_callback_catch_unwind<F>(future: F) -> std::thread::Result<F::Output>
where
F: Future,
{
let mut future = std::pin::pin!(future);
std::future::poll_fn(move |context| {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
future.as_mut().poll(context)
})) {
Ok(std::task::Poll::Ready(value)) => std::task::Poll::Ready(Ok(value)),
Ok(std::task::Poll::Pending) => std::task::Poll::Pending,
Err(payload) => std::task::Poll::Ready(Err(payload)),
}
})
.await
}
#[cfg(feature = "websocket-experimental")]
struct WebSocketReverseCallbackCompletionGuard {
terminal: Arc<WebSocketReverseCallbackTerminal>,
state: Arc<ReverseCallbackState>,
cancellation: ReverseRequestCancellation,
request_id: RequestId,
cx: Cx,
armed: bool,
}
#[cfg(feature = "websocket-experimental")]
impl WebSocketReverseCallbackCompletionGuard {
fn new(
terminal: Arc<WebSocketReverseCallbackTerminal>,
state: Arc<ReverseCallbackState>,
cancellation: ReverseRequestCancellation,
request_id: RequestId,
cx: Cx,
) -> Self {
Self {
terminal,
state,
cancellation,
request_id,
cx,
armed: true,
}
}
fn disarm(&mut self) {
self.armed = false;
}
fn fail(&mut self, error: McpError) {
self.armed = false;
self.terminal.publish(&self.cx, error.clone());
self.state.fail_connection(error);
}
}
#[cfg(feature = "websocket-experimental")]
impl Drop for WebSocketReverseCallbackCompletionGuard {
fn drop(&mut self) {
if self.armed && self.cancellation.is_open() {
let error = McpError::internal_error("WebSocket reverse callback task panicked");
self.terminal.publish(&self.cx, error.clone());
self.state.fail_connection(error);
self.state.complete(&self.request_id);
}
}
}
#[cfg(feature = "websocket-experimental")]
impl WebSocketReverseCallbackTerminal {
fn error(&self) -> Option<McpError> {
self.error
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
fn subscribe(&self, cx: &Cx) -> oneshot::Receiver<()> {
let (sender, receiver) = oneshot::channel();
let already_terminal = self.error().is_some();
if already_terminal {
let _ = sender.send(cx, ());
return receiver;
}
let mut waiter = self
.waiter
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if self.error().is_some() {
let _ = sender.send(cx, ());
} else {
// The only receive owner replaces the stale sender left behind by
// its previously completed cancel-safe race.
*waiter = Some(sender);
}
receiver
}
fn publish(&self, cx: &Cx, error: McpError) {
let should_wake = {
let mut stored = self
.error
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if stored.is_some() {
false
} else {
*stored = Some(error);
true
}
};
if should_wake
&& let Some(sender) = self
.waiter
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
{
let _ = sender.send(cx, ());
}
}
}
#[cfg(feature = "websocket-experimental")]
enum WebSocketReceiveRace<IO>
where
IO: AsyncRead + AsyncWrite + Unpin,
{
Frame {
receiver: AsyncWsClientRecvHalf<IO>,
result: Box<Result<ReceivedTransportFrame, TransportError>>,
},
CallbackTerminal,
}
#[cfg(feature = "websocket-experimental")]
impl<IO> WebSocketReverseCallbackPool<IO>
where
IO: AsyncRead + AsyncWrite + Unpin,
{
fn new(response_sender: Arc<AsyncMutex<AsyncWsClientSendHalf<IO>>>, cx: Cx) -> Self {
Self {
state: Arc::new(ReverseCallbackState::default()),
terminal: Arc::new(WebSocketReverseCallbackTerminal::default()),
response_sender,
cx,
tasks: Mutex::new(Vec::new()),
}
}
fn dispatch<P, R>(
&self,
request_id: RequestId,
params: P,
handler: Arc<
dyn for<'callback> Fn(
&'callback Cx,
ReverseRequestCancellation,
P,
) -> ReverseRequestFuture<'callback, R>
+ Send
+ Sync,
>,
) -> McpResult<()>
where
IO: Send + 'static,
P: Send + 'static,
R: serde::Serialize + Send + 'static,
{
self.reap_finished_tasks()?;
let mut tasks = self.tasks.lock().map_err(|_| {
McpError::internal_error("WebSocket reverse callback task registry failed")
})?;
if tasks.len() >= MAX_QUEUED_REVERSE_CALLBACKS {
return Err(McpError::internal_error(
"WebSocket reverse callback capacity exceeded",
));
}
let cancellation = self.state.admit(&request_id)?;
let callback_state = Arc::clone(&self.state);
let callback_terminal = Arc::clone(&self.terminal);
let terminal_cx = self.cx.clone();
let response_sender = Arc::clone(&self.response_sender);
let callback_id = request_id.clone();
let response_id = request_id.clone();
let invoke_cancellation = cancellation.clone();
let task_cx = Cx::current().unwrap_or_else(|| self.cx.clone());
let task = match task_cx.spawn(move |callback_cx| async move {
let mut completion_guard = WebSocketReverseCallbackCompletionGuard::new(
Arc::clone(&callback_terminal),
Arc::clone(&callback_state),
cancellation.clone(),
callback_id.clone(),
terminal_cx.clone(),
);
let result = match websocket_callback_catch_unwind(handler(
&callback_cx,
invoke_cancellation,
params,
))
.await
{
Ok(result) => result,
Err(_) => {
let error =
McpError::internal_error("WebSocket reverse callback task panicked");
completion_guard.fail(error);
callback_state.complete(&callback_id);
return;
}
};
let response = reverse_request_response(response_id, result);
match commit_websocket_reverse_callback_response(
&callback_state,
&callback_id,
&response_sender,
&callback_cx,
&cancellation,
&response,
)
.await
{
Ok(_) => {
completion_guard.disarm();
callback_state.complete(&callback_id);
}
// A matching protocol cancellation (or structured close)
// aborts this callback's Cx while it may be queued on the
// writer. That is a successful suppression, not a transport
// write failure and therefore not connection-terminal.
Err(TransportError::Cancelled) if !cancellation.is_open() => {
completion_guard.disarm();
callback_state.complete(&callback_id);
}
Err(error) => {
let error = transport_error_to_mcp(error);
completion_guard.fail(error);
callback_state.complete(&callback_id);
}
}
}) {
Ok(task) => task,
Err(_) => {
self.state.complete(&request_id);
return Err(McpError::internal_error(
"WebSocket reverse callback dispatcher is unavailable",
));
}
};
tasks.push((request_id, task));
Ok(())
}
fn cancel(&self, request_id: &RequestId) -> bool {
let cancelled = self.state.cancel(request_id);
if cancelled {
let tasks = self
.tasks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for (task_id, task) in tasks.iter() {
if task_id.correlates_with(request_id) {
task.abort();
}
}
}
cancelled
}
fn cancel_all(&self) {
self.state.cancel_all();
let tasks = self
.tasks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for (_, task) in tasks.iter() {
task.abort();
}
}
fn reap_finished_tasks(&self) -> McpResult<usize> {
let mut tasks = self.tasks.lock().map_err(|_| {
McpError::internal_error("WebSocket reverse callback task registry failed")
})?;
let mut active = Vec::with_capacity(tasks.len());
let mut panicked = false;
for (request_id, mut task) in std::mem::take(&mut *tasks) {
match task.try_join() {
Ok(None) => active.push((request_id, task)),
Ok(Some(())) | Err(asupersync::runtime::JoinError::Cancelled(_)) => {}
Err(
asupersync::runtime::JoinError::Panicked(_)
| asupersync::runtime::JoinError::PolledAfterCompletion,
) => panicked = true,
}
}
let active_count = active.len();
*tasks = active;
if panicked {
let error = McpError::internal_error("WebSocket reverse callback task panicked");
// Publish while the task registry remains locked: a subsequent
// reverse request cannot turn this observed panic into an
// immediate error response before the sole receive owner is woken.
self.terminal.publish(&self.cx, error.clone());
self.state.fail_connection(error.clone());
return Err(error);
}
Ok(active_count)
}
async fn join_bounded(&self, cx: &Cx) -> McpResult<()> {
self.cancel_all();
for _ in 0..REVERSE_CALLBACK_SHUTDOWN_POLLS {
if self.reap_finished_tasks()? == 0 {
return Ok(());
}
asupersync::time::sleep(cx.now(), REVERSE_CALLBACK_POLL_SLICE).await;
}
Err(McpError::internal_error(
"WebSocket reverse callback workers did not stop within the shutdown bound",
))
}
fn terminal_error(&self) -> Option<McpError> {
self.terminal
.error()
.or_else(|| self.state.terminal_error())
}
}
#[cfg(feature = "websocket-experimental")]
impl<IO> Drop for WebSocketReverseCallbackPool<IO>
where
IO: AsyncRead + AsyncWrite + Unpin,
{
fn drop(&mut self) {
self.cancel_all();
}
}
/// Serializes an exact-2024 callback response with ordinary client writes.
///
/// Taking the writer lock before the response election lets the sole reader
/// receive a matching cancellation while this callback is queued behind a
/// write. That cancellation then aborts this task's `Cx` and removes the
/// active election before any callback frame can be emitted.
#[cfg(feature = "websocket-experimental")]
async fn commit_websocket_reverse_callback_response<IO>(
state: &ReverseCallbackState,
request_id: &RequestId,
sender: &Arc<AsyncMutex<AsyncWsClientSendHalf<IO>>>,
cx: &Cx,
cancellation: &ReverseRequestCancellation,
response: &JsonRpcMessage,
) -> Result<bool, TransportError>
where
IO: AsyncRead + AsyncWrite + Unpin,
{
let mut sender = OwnedMutexGuard::lock(Arc::clone(sender), cx)
.await
.map_err(|error| match error {
asupersync::sync::LockError::Cancelled => TransportError::Cancelled,
asupersync::sync::LockError::TimedOut(_) => TransportError::Timeout,
asupersync::sync::LockError::Poisoned
| asupersync::sync::LockError::PolledAfterCompletion => TransportError::Closed,
})?;
if !state.claim_response_if_open(request_id, cancellation) {
return Ok(false);
}
sender.send(cx, response).await?;
Ok(true)
}
#[cfg(feature = "websocket-experimental")]
async fn send_websocket_callback_message<IO>(
sender: &Arc<AsyncMutex<AsyncWsClientSendHalf<IO>>>,
cx: &Cx,
message: &JsonRpcMessage,
) -> Result<(), TransportError>
where
IO: AsyncRead + AsyncWrite + Unpin,
{
let mut sender = OwnedMutexGuard::lock(Arc::clone(sender), cx)
.await
.map_err(|error| match error {
asupersync::sync::LockError::Cancelled => TransportError::Cancelled,
asupersync::sync::LockError::TimedOut(_) => TransportError::Timeout,
asupersync::sync::LockError::Poisoned
| asupersync::sync::LockError::PolledAfterCompletion => TransportError::Closed,
})?;
sender.send(cx, message).await
}
/// One incrementally driven final catalog subscription on a WebSocket client.
///
/// The receive half stays owned by [`WebSocketClient`], so ordinary requests
/// such as `tools/list` can still run while catalog events are drained one at
/// a time. Collect-to-terminal [`WebSocketClient::listen_subscriptions_typed`]
/// occupies ingress until the stream ends.
#[cfg(feature = "websocket-experimental")]
struct LiveWebSocketCatalogSubscription {
request_id: RequestId,
core_request: CoreRequest,
requested_filter: SubscriptionFilter,
accepted_filter: Option<SubscriptionFilter>,
acknowledgement_delivered: bool,
pending_notifications: VecDeque<ServerNotification>,
terminal_response: Option<(JsonRpcResponse, Option<String>)>,
}
/// One incrementally driven official Tasks subscription on a WebSocket client.
///
/// `notifications/tasks` is not a [`ServerNotification`] variant, so this
/// listener keeps its own typed queue. Catalog listen stays mutually exclusive.
#[cfg(all(feature = "websocket-experimental", feature = "tasks"))]
struct LiveWebSocketTaskSubscription {
request_id: RequestId,
core_request: CoreRequest,
requested_filter: SubscriptionFilter,
accepted_filter: Option<SubscriptionFilter>,
acknowledgement_delivered: bool,
pending_notifications: VecDeque<FinalTaskStatusNotification>,
terminal_response: Option<(JsonRpcResponse, Option<String>)>,
}
/// Caller-`Cx` asynchronous MCP client over a native WebSocket transport.
///
/// The client owns one source-preserving receive half and one independently
/// cancellable send/close half obtained from [`AsyncWsClientTransport`]. Its
/// negotiated era is frozen before this value is returned. Exact legacy is
/// selected only by `LegacyOnly`; `Auto` requires a fresh-transport factory,
/// performs final discovery once, and never replays a legacy initialize
/// request on the refused connection.
#[cfg(feature = "websocket-experimental")]
pub struct WebSocketClient<IO>
where
IO: AsyncRead + AsyncWrite + Unpin,
{
receiver: Option<AsyncWsClientRecvHalf<IO>>,
sender: Arc<AsyncMutex<AsyncWsClientSendHalf<IO>>>,
connection_cx: Cx,
session: ClientSession,
reverse_request_handlers: ReverseRequestHandlers,
reverse_callback_pool: WebSocketReverseCallbackPool<IO>,
final_server_notifications: VecDeque<ServerNotification>,
final_progress_notifications: VecDeque<FinalProgressNotificationParams>,
/// Exact-2024 server notifications retained from the WebSocket receive loop.
legacy_server_notifications: VecDeque<JsonRpcRequest>,
retired_response_keys: VecDeque<CorrelationKey>,
live_catalog_subscription: Option<LiveWebSocketCatalogSubscription>,
#[cfg(feature = "tasks")]
live_task_subscription: Option<LiveWebSocketTaskSubscription>,
next_id: u64,
closed: bool,
close_settled: bool,
/// Modern request-only `io.modelcontextprotocol/logLevel`.
final_log_level: Option<LoggingLevel>,
}
#[cfg(feature = "websocket-experimental")]
impl<IO> std::fmt::Debug for WebSocketClient<IO>
where
IO: AsyncRead + AsyncWrite + Unpin,
{
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("WebSocketClient")
.field("selected_era", &self.session.selected_era())
.field("protocol_plan", self.session.protocol_plan())
.field("closed", &self.closed)
.field("close_settled", &self.close_settled)
.finish_non_exhaustive()
}
}
#[cfg(feature = "websocket-experimental")]
impl<IO> WebSocketClient<IO>
where
IO: AsyncRead + AsyncWrite + Unpin,
{
/// Negotiates a native, already-established async WebSocket transport.
pub async fn connect_with_cx(
cx: &Cx,
protocol_plan: ClientProtocolPlan,
client_info: ClientInfo,
client_capabilities: ClientCapabilities,
transport: AsyncWsClientTransport<IO>,
) -> McpResult<Self> {
Self::connect_with_reverse_request_handlers_with_cx(
cx,
protocol_plan,
client_info,
client_capabilities,
ReverseRequestHandlers::new(),
transport,
)
.await
}
/// Negotiates one native WebSocket connection with exact-2024 reverse
/// handlers. Handler-derived capabilities are admitted only for an
/// explicitly selected exact legacy connection.
pub async fn connect_with_reverse_request_handlers_with_cx(
cx: &Cx,
protocol_plan: ClientProtocolPlan,
client_info: ClientInfo,
client_capabilities: ClientCapabilities,
reverse_request_handlers: ReverseRequestHandlers,
transport: AsyncWsClientTransport<IO>,
) -> McpResult<Self> {
Self::connect_with_builder_configuration_with_cx(
cx,
protocol_plan,
client_info,
None,
client_capabilities,
reverse_request_handlers,
None,
None,
transport,
)
.await
}
pub(crate) async fn connect_with_builder_configuration_with_cx(
cx: &Cx,
protocol_plan: ClientProtocolPlan,
client_info: ClientInfo,
client_implementation: Option<fastmcp_protocol::common_types::Implementation>,
mut client_capabilities: ClientCapabilities,
reverse_request_handlers: ReverseRequestHandlers,
mcp_apps_settings: Option<McpAppsClientSettings>,
client_extension_runtime: Option<Arc<ClientExtensionRuntime>>,
transport: AsyncWsClientTransport<IO>,
) -> McpResult<Self> {
validate_protocol_plan_feature(&protocol_plan)?;
cx.checkpoint().map_err(|_| McpError::request_cancelled())?;
match protocol_plan.policy() {
ProtocolPolicy::LegacyOnly => {
reverse_request_handlers.derive_legacy_capabilities(&mut client_capabilities);
reverse_request_handlers.validate_legacy_capabilities(&client_capabilities)?;
}
ProtocolPolicy::ModernOnly if !reverse_request_handlers.is_empty() => {
return Err(McpError::invalid_params(
"MCP 2026-07-28 does not support exact-2024 reverse request handlers",
));
}
ProtocolPolicy::ModernOnly => {
reverse_request_handlers.derive_modern_capabilities(&mut client_capabilities);
}
ProtocolPolicy::Auto => {}
}
if matches!(protocol_plan.policy(), ProtocolPolicy::Auto) {
return Err(McpError::invalid_params(
"WebSocket Auto requires connect_auto_with_cx with a fresh transport factory",
));
}
let (mut receiver, mut sender) = transport.into_split();
let handshake = match protocol_plan.policy() {
ProtocolPolicy::ModernOnly => {
async_websocket_discover(
&mut receiver,
&mut sender,
cx,
&client_info,
&client_capabilities,
mcp_apps_settings.as_ref(),
client_extension_runtime.as_deref(),
1,
)
.await
}
ProtocolPolicy::LegacyOnly => async_websocket_initialize(
&mut receiver,
&mut sender,
cx,
&client_info,
&client_capabilities,
1,
)
.await
.map(WebSocketInitialization::Legacy),
ProtocolPolicy::Auto => unreachable!("Auto was rejected before transport ownership"),
};
let initialization = match handshake {
Ok(initialization) => initialization,
Err(error) => {
// The split connection is owned from this point onward. Every
// handshake failure therefore closes both halves before its
// original semantic error is returned to the caller.
let _ = close_async_websocket_halves(&mut receiver, &mut sender, cx).await;
return Err(match (protocol_plan.policy(), error) {
(ProtocolPolicy::ModernOnly, WebSocketHandshakeError::MethodNotFound) => {
McpError::method_not_found(SERVER_DISCOVER_METHOD)
}
(ProtocolPolicy::LegacyOnly, WebSocketHandshakeError::MethodNotFound) => {
McpError::invalid_request(
"WebSocket initialize was not supported by the peer",
)
}
(_, WebSocketHandshakeError::Mcp(error)) => error,
(ProtocolPolicy::Auto, _) => {
unreachable!("Auto was rejected before transport ownership")
}
});
}
};
if matches!(initialization, WebSocketInitialization::Legacy(_)) {
if let Err(error) = sender
.send(
cx,
&JsonRpcMessage::Request(JsonRpcRequest::initialized_notification()),
)
.await
{
let _ = close_async_websocket_halves(&mut receiver, &mut sender, cx).await;
return Err(transport_error_to_mcp(error));
}
}
// Once the WebSocket halves are split, every later admission error
// must use the same close-and-settle exit as handshake failures. In
// particular, incompatible generic extensions/Apps must not leak an
// open native connection after discovery succeeded.
let session = (|| {
let mut session = match initialization {
WebSocketInitialization::Legacy(result) => ClientSession::try_new(
client_info,
client_capabilities,
result.server_info,
result.capabilities,
result.protocol_version,
)
.map(|session| session.with_legacy_instructions(result.instructions)),
WebSocketInitialization::Modern {
server_info,
discovery,
} => ClientSession::try_new(
client_info,
client_capabilities,
server_info,
ServerCapabilities::default(),
MODERN_PROTOCOL_VERSION.to_owned(),
)
.map(|session| session.with_server_discovery(discovery)),
}
.map_err(|_| McpError::internal_error(UNSUPPORTED_PROTOCOL_VERSION_ERROR))?;
if let Some(implementation) = client_implementation {
session = session.with_client_implementation(implementation);
}
session = session
.with_mcp_apps_settings(mcp_apps_settings)
.with_client_extension_runtime(client_extension_runtime);
session.negotiate_client_extensions_after_discovery()?;
let apps_activation_receipt = if session.generic_mcp_apps_configured() {
session.generic_mcp_apps_activation_receipt()
} else {
session.server_discovery().and_then(|discovery| {
mcp_apps_activation_receipt(session.mcp_apps_settings(), discovery)
})
};
session.set_mcp_apps_activation_receipt(apps_activation_receipt);
session.try_with_protocol_plan(protocol_plan).map_err(|_| {
McpError::internal_error("Configured protocol policy rejects the negotiated era")
})
})();
let session =
admit_websocket_session_or_close(&mut receiver, &mut sender, cx, session).await?;
let sender = Arc::new(AsyncMutex::with_name("websocket-client-send", sender));
let reverse_callback_pool =
WebSocketReverseCallbackPool::new(Arc::clone(&sender), cx.clone());
Ok(Self {
receiver: Some(receiver),
sender,
connection_cx: cx.clone(),
session,
reverse_request_handlers,
reverse_callback_pool,
final_server_notifications: VecDeque::new(),
final_progress_notifications: VecDeque::new(),
legacy_server_notifications: VecDeque::new(),
retired_response_keys: VecDeque::new(),
live_catalog_subscription: None,
#[cfg(feature = "tasks")]
live_task_subscription: None,
next_id: 2,
closed: false,
close_settled: false,
final_log_level: None,
})
}
/// Negotiates Auto over at most two fresh caller-owned transports.
///
/// The factory is invoked once for final discovery and, only after its
/// correlated `MethodNotFound` refusal, once more for exact-2024
/// initialization. No request is replayed on the refused connection.
pub async fn connect_auto_with_cx<F, Fut>(
cx: &Cx,
client_info: ClientInfo,
client_capabilities: ClientCapabilities,
fresh_transport: F,
) -> McpResult<Self>
where
F: FnMut(&Cx) -> Fut,
Fut: Future<Output = McpResult<AsyncWsClientTransport<IO>>>,
{
Self::connect_auto_with_reverse_request_handlers_with_cx(
cx,
client_info,
client_capabilities,
ReverseRequestHandlers::new(),
fresh_transport,
)
.await
}
/// Negotiates Auto over at most two fresh transports, retaining exact
/// reverse handlers solely for the authorized fresh legacy attempt.
pub async fn connect_auto_with_reverse_request_handlers_with_cx<F, Fut>(
cx: &Cx,
client_info: ClientInfo,
client_capabilities: ClientCapabilities,
reverse_request_handlers: ReverseRequestHandlers,
fresh_transport: F,
) -> McpResult<Self>
where
F: FnMut(&Cx) -> Fut,
Fut: Future<Output = McpResult<AsyncWsClientTransport<IO>>>,
{
Self::connect_auto_with_builder_configuration_with_cx(
cx,
client_info,
client_capabilities,
reverse_request_handlers,
None,
None,
fresh_transport,
)
.await
}
pub(crate) async fn connect_auto_with_builder_configuration_with_cx<F, Fut>(
cx: &Cx,
client_info: ClientInfo,
client_capabilities: ClientCapabilities,
reverse_request_handlers: ReverseRequestHandlers,
mcp_apps_settings: Option<McpAppsClientSettings>,
client_extension_runtime: Option<Arc<ClientExtensionRuntime>>,
mut fresh_transport: F,
) -> McpResult<Self>
where
F: FnMut(&Cx) -> Fut,
Fut: Future<Output = McpResult<AsyncWsClientTransport<IO>>>,
{
let auto_plan = ClientProtocolPlan::websocket(ProtocolPolicy::Auto);
validate_protocol_plan_feature(&auto_plan)?;
cx.checkpoint().map_err(|_| McpError::request_cancelled())?;
let first = fresh_transport(cx).await?;
match Self::connect_with_builder_configuration_with_cx(
cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
client_info.clone(),
None,
client_capabilities.clone(),
ReverseRequestHandlers::new(),
mcp_apps_settings.clone(),
client_extension_runtime.clone(),
first,
)
.await
{
Ok(mut client) => {
client.session.set_protocol_plan(auto_plan.clone());
Ok(client)
}
Err(error) if error.code == McpErrorCode::MethodNotFound => {
cx.checkpoint().map_err(|_| McpError::request_cancelled())?;
let fresh_legacy = fresh_transport(cx).await?;
let mut client = Self::connect_with_builder_configuration_with_cx(
cx,
ClientProtocolPlan::websocket(ProtocolPolicy::LegacyOnly),
client_info,
None,
client_capabilities,
reverse_request_handlers,
mcp_apps_settings,
client_extension_runtime,
fresh_legacy,
)
.await?;
client.session.set_protocol_plan(auto_plan);
Ok(client)
}
Err(error) => Err(error),
}
}
/// Returns the immutable session established by the handshake.
#[must_use]
pub const fn session(&self) -> &ClientSession {
&self.session
}
/// Returns the era frozen by the completed handshake.
#[must_use]
pub const fn selected_protocol_era(&self) -> ProtocolEra {
self.session
.selected_era()
.expect("negotiated WebSocket clients always select an era")
}
/// Drains non-progress final server notifications received during modern requests.
#[must_use]
pub fn take_final_server_notifications(&mut self) -> Vec<ServerNotification> {
self.final_server_notifications.drain(..).collect()
}
/// Drains exact final progress notifications received during modern requests.
#[must_use]
pub fn take_final_progress_notifications(&mut self) -> Vec<FinalProgressNotificationParams> {
self.final_progress_notifications.drain(..).collect()
}
/// Pops one exact-2024 server notification retained by the WebSocket receive loop.
#[must_use]
pub fn take_legacy_notification(&mut self) -> Option<JsonRpcRequest> {
self.legacy_server_notifications.pop_front()
}
/// Drains exact-2024 server notifications retained by the WebSocket receive loop.
#[must_use]
pub fn take_legacy_notifications(&mut self) -> Vec<JsonRpcRequest> {
self.legacy_server_notifications.drain(..).collect()
}
/// Sends one admitted client request and retains the exact peer result source.
///
/// The frozen negotiated era admits the method, direction, envelope, and
/// parameter shape before this method allocates an ID or writes a frame.
pub async fn request_with_raw_result(
&mut self,
cx: &Cx,
method: impl Into<String>,
params: Option<serde_json::Value>,
) -> McpResult<WebSocketResponse>
where
IO: Send + 'static,
{
if cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
if self.closed {
return Err(McpError::internal_error("WebSocket client is closed"));
}
let method = method.into();
self.admit_raw_client_request(&method, params.as_ref())?;
let id = self.allocate_request_id()?;
let request = JsonRpcRequest::new(method, params, id.clone());
self.request_admitted_with_raw_result(cx, request, None)
.await
}
async fn request_admitted_with_raw_result(
&mut self,
cx: &Cx,
request: JsonRpcRequest,
cancellation: Option<&McpRequestCancellation>,
) -> McpResult<WebSocketResponse>
where
IO: Send + 'static,
{
if cx.checkpoint().is_err()
|| cancellation.is_some_and(McpRequestCancellation::is_cancel_requested)
{
return Err(McpError::request_cancelled());
}
if self.closed {
return Err(McpError::internal_error("WebSocket client is closed"));
}
let id = request.id.clone().ok_or_else(|| {
McpError::internal_error("WebSocket admitted request requires a JSON-RPC ID")
})?;
if let Err(error) = self
.send_message(cx, &JsonRpcMessage::Request(request))
.await
{
return Err(self.terminal_transport_error(cx, error).await);
}
loop {
if cancellation.is_some_and(McpRequestCancellation::is_cancel_requested) {
return Err(self.retire_committed_request_after_cancellation(id).await);
}
if let Err(error) = self.drain_completed_reverse_callbacks() {
return Err(self.terminal_callback_error(cx, error).await);
}
let frame = match self.recv_with_callback_terminal(cx, cancellation).await {
Ok(frame) => frame,
Err(_) if self.reverse_callback_pool.terminal_error().is_some() => {
return Err(self
.terminal_callback_error(
cx,
self.reverse_callback_pool
.terminal_error()
.expect("terminal callback error was observed"),
)
.await);
}
Err(TransportError::Cancelled)
if cx.is_cancel_requested()
|| cancellation
.is_some_and(McpRequestCancellation::is_cancel_requested) =>
{
return Err(self.retire_committed_request_after_cancellation(id).await);
}
Err(error) => return Err(self.terminal_transport_error(cx, error).await),
};
let JsonRpcMessage::Response(response) = frame.message() else {
if let Err(error) = self.handle_unsolicited_websocket_request(cx, &frame).await {
return Err(self.terminal_callback_error(cx, error).await);
}
continue;
};
if self.discard_retired_websocket_response(response) {
continue;
}
if self
.live_catalog_subscription
.as_ref()
.is_some_and(|subscription| {
response.id.as_ref().is_some_and(|response_id| {
response_id.correlates_with(&subscription.request_id)
})
})
{
let response = response.clone();
let raw_result = raw_result_from_admitted_response(&response, frame, "WebSocket")?;
let Some(subscription) = self.live_catalog_subscription.as_mut() else {
continue;
};
if subscription.terminal_response.is_some() {
return Err(self
.close_after_protocol_error(
cx,
"WebSocket catalog listener received a duplicate terminal response",
)
.await);
}
subscription.terminal_response = Some((response, raw_result));
continue;
}
#[cfg(feature = "tasks")]
if self
.live_task_subscription
.as_ref()
.is_some_and(|subscription| {
response.id.as_ref().is_some_and(|response_id| {
response_id.correlates_with(&subscription.request_id)
})
})
{
let response = response.clone();
let raw_result = raw_result_from_admitted_response(&response, frame, "WebSocket")?;
let Some(subscription) = self.live_task_subscription.as_mut() else {
continue;
};
if subscription.terminal_response.is_some() {
return Err(self
.close_after_protocol_error(
cx,
"WebSocket Tasks listener received a duplicate terminal response",
)
.await);
}
subscription.terminal_response = Some((response, raw_result));
continue;
}
if !response
.id
.as_ref()
.is_some_and(|response_id| response_id.correlates_with(&id))
{
return Err(self
.close_after_protocol_error(
cx,
"WebSocket response ID does not match the active request",
)
.await);
}
let response = response.clone();
let raw_result = raw_result_from_admitted_response(&response, frame, "WebSocket")?;
if let Err(error) = self.drain_completed_reverse_callbacks() {
return Err(self.terminal_callback_error(cx, error).await);
}
return Ok(WebSocketResponse {
response,
raw_result,
});
}
}
/// Consumes a terminal response only when it belongs to a locally retired
/// cancelled request. All other responses remain attributable to the live
/// receive owner and are therefore checked by that owner.
fn discard_retired_websocket_response(&mut self, response: &JsonRpcResponse) -> bool {
let Some(retired_position) = response.id.as_ref().and_then(|response_id| {
response_id.correlation_key().ok().and_then(|response_key| {
self.retired_response_keys
.iter()
.position(|retired_key| retired_key == &response_key)
})
}) else {
return false;
};
self.retired_response_keys.remove(retired_position);
true
}
fn retain_modern_websocket_notification(
&mut self,
frame: &ReceivedTransportFrame,
) -> McpResult<bool> {
let JsonRpcMessage::Request(request) = frame.message() else {
return Ok(false);
};
if request.id.is_some() || !is_final_server_notification_method(request) {
return Ok(false);
}
if request.method == "notifications/cancelled" {
CancellationWireMessage::decode(
ProtocolEra::Modern2026,
CancellationSender::Server,
request,
)
.map_err(|_| McpError::invalid_request("WebSocket server cancellation is invalid"))?;
return Ok(true);
}
let raw_params = raw_notification_params_from_frame(frame.source())?;
let notification = decode_final_server_notification(request, raw_params.as_deref())
.map_err(|error| {
McpError::invalid_request(format!(
"WebSocket final server notification is invalid: {error}"
))
})?;
let ServerNotification::Progress(progress) = notification else {
if self.final_server_notifications.len() >= MAX_QUEUED_FINAL_SERVER_NOTIFICATIONS {
return Err(McpError::invalid_request(
FINAL_SERVER_NOTIFICATION_QUEUE_OVERFLOW_ERROR,
));
}
self.final_server_notifications.push_back(notification);
return Ok(true);
};
if self.final_progress_notifications.len() >= MAX_QUEUED_FINAL_SERVER_NOTIFICATIONS {
return Err(McpError::invalid_request(
FINAL_SERVER_NOTIFICATION_QUEUE_OVERFLOW_ERROR,
));
}
self.final_progress_notifications.push_back(progress);
Ok(true)
}
fn retain_legacy_websocket_notification(
&mut self,
request: &JsonRpcRequest,
) -> McpResult<bool> {
if !is_legacy_server_notification_method(request) {
return Ok(false);
}
if self.legacy_server_notifications.len() >= MAX_QUEUED_FINAL_SERVER_NOTIFICATIONS {
return Err(McpError::invalid_request(
LEGACY_SERVER_NOTIFICATION_QUEUE_OVERFLOW_ERROR,
));
}
self.legacy_server_notifications.push_back(request.clone());
Ok(true)
}
async fn handle_unsolicited_websocket_request(
&mut self,
cx: &Cx,
frame: &ReceivedTransportFrame,
) -> McpResult<()>
where
IO: Send + 'static,
{
let JsonRpcMessage::Request(request) = frame.message() else {
return Err(McpError::invalid_request(
"WebSocket ingress is neither a response nor a request",
));
};
if self.selected_protocol_era() == ProtocolEra::Modern2026 {
#[cfg(feature = "tasks")]
if self.retain_live_task_status_notification(frame)? {
return Ok(());
}
if self.retain_modern_websocket_notification(frame)? {
return Ok(());
}
if let Some(dispatch) = self.try_dispatch_modern_reverse_request(request)? {
if let LiveServerRequestDispatch::Immediate(reverse_response) = dispatch {
self.send_message(cx, &reverse_response)
.await
.map_err(transport_error_to_mcp)?;
}
return Ok(());
}
return Err(McpError::invalid_request(
"MCP 2026-07-28 WebSocket client rejected a non-notification server request",
));
}
if self.retain_legacy_websocket_notification(request)? {
if request.method == "notifications/cancelled" {
let _ = self.cancel_legacy_reverse_callback(request);
}
return Ok(());
}
if request.method == "notifications/cancelled" {
let _ = self.cancel_legacy_reverse_callback(request);
return Ok(());
}
let reverse_dispatch = self.dispatch_legacy_reverse_request(request).map_err(|_| {
McpError::invalid_request("WebSocket exact-2024 reverse request is invalid")
})?;
if let LiveServerRequestDispatch::Immediate(reverse_response) = reverse_dispatch {
self.send_message(cx, &reverse_response)
.await
.map_err(transport_error_to_mcp)?;
}
Ok(())
}
async fn retire_committed_request_after_cancellation(
&mut self,
request_id: RequestId,
) -> McpError
where
IO: Send + 'static,
{
let request_key = match request_id.correlation_key() {
Ok(key) => key,
Err(error) => {
return McpError::invalid_params(format!(
"invalid WebSocket cancellation request ID: {error}"
));
}
};
if self.retired_response_keys.len() >= MAX_QUEUED_WEBSOCKET_CANCELLED_RESPONSE_KEYS {
let connection_cx = self.connection_cx.clone();
return self
.close_after_protocol_error(
&connection_cx,
"WebSocket cancelled-response tombstone capacity exceeded",
)
.await;
}
let cancellation = match self.selected_protocol_era() {
ProtocolEra::Legacy2024 => CancellationWireMessage::Legacy2024 {
sender: CancellationSender::Client,
params: CancelledParams {
request_id: request_id.clone(),
reason: None,
},
},
ProtocolEra::Modern2026 => CancellationWireMessage::Modern2026 {
sender: CancellationSender::Client,
params: FinalCancelledNotificationParams {
request_id: request_id.clone(),
reason: None,
meta: None,
additional: BTreeMap::default(),
},
},
};
let control = match cancellation.encode().map(JsonRpcMessage::Request) {
Ok(control) => control,
Err(error) => {
return McpError::invalid_params(format!(
"invalid WebSocket cancellation: {error}"
));
}
};
// Install before sending control so a peer racing a terminal response
// cannot make the next receive owner mis-correlate it.
self.retired_response_keys.push_back(request_key);
let connection_cx = self.connection_cx.clone();
// Cancellation notification delivery is the short commit-critical
// boundary after tombstone installation. Mask only its individual
// polls so a caller that reused the connection Cx for its operation
// cannot suppress the required control frame.
let cancellation_send = {
let mut send = Box::pin(self.send_message(&connection_cx, &control));
let masked_send = std::future::poll_fn(|task_cx| {
connection_cx.masked(|| send.as_mut().poll(task_cx))
});
asupersync::time::timeout_at(
connection_cx
.now()
.saturating_add_nanos(WEBSOCKET_CANCELLATION_CONTROL_SEND_TIMEOUT_NANOS),
masked_send,
)
.await
};
match cancellation_send {
Ok(Ok(())) => {}
Ok(Err(error)) => return self.terminal_transport_error(&connection_cx, error).await,
Err(_) => {
return self
.terminal_transport_error(&connection_cx, TransportError::Timeout)
.await;
}
}
// A compliant peer may suppress a cancelled request's terminal
// response forever. The tombstone belongs to the next receive owner:
// it discards exactly this late response if one eventually arrives,
// while a different response remains attributable to that active
// request or is a protocol error. Do not wait here.
McpError::request_cancelled()
}
/// Closes both transport halves through the supplied caller context.
pub async fn close(&mut self, cx: &Cx) -> McpResult<()> {
if self.close_settled {
return Ok(());
}
self.closed = true;
self.live_catalog_subscription = None;
self.settle_close(cx).await
}
/// Completes one prompt or resource-template argument in MCP 2026-07-28.
///
/// Exact 2024 sessions reject before allocating an ID or writing a frame.
pub async fn complete(&mut self, cx: &Cx, params: CompletionParams) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
match self.selected_protocol_era() {
ProtocolEra::Modern2026 => {
let params = serde_json::to_value(params).map_err(|_| {
McpError::invalid_params(
"Modern WebSocket completion parameters could not serialize",
)
})?;
self.request_final_core(cx, "completion/complete", params)
.await
}
ProtocolEra::Legacy2024 => {
let params = serde_json::to_value(params.into_legacy()?).map_err(|_| {
McpError::invalid_params(
"Exact legacy WebSocket completion parameters could not serialize",
)
})?;
self.request_selected_core(cx, "completion/complete", params)
.await
}
}
}
/// Completes one prompt or resource-template argument under a caller-owned
/// cancellation domain.
pub async fn complete_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
params: CompletionParams,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
let parameters = match self.selected_protocol_era() {
ProtocolEra::Modern2026 => serde_json::to_value(params).map_err(|_| {
McpError::invalid_params(
"Modern WebSocket completion parameters could not serialize",
)
})?,
ProtocolEra::Legacy2024 => {
serde_json::to_value(params.into_legacy()?).map_err(|_| {
McpError::invalid_params(
"Exact legacy WebSocket completion parameters could not serialize",
)
})?
}
};
self.request_core_verb_with_cancellation(
cx,
cancellation,
"completion/complete",
parameters,
)
.await
}
/// Completes one prompt or resource-template argument and admits
/// request-scoped `notifications/progress` for the supplied marker.
pub async fn complete_with_progress_marker(
&mut self,
cx: &Cx,
params: CompletionParams,
progress_marker: ProgressMarker,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
let token = serde_json::to_value(progress_marker).map_err(|_| {
McpError::internal_error("WebSocket progress token could not be encoded")
})?;
let mut parameters = match self.selected_protocol_era() {
ProtocolEra::Modern2026 => serde_json::to_value(params).map_err(|_| {
McpError::invalid_params(
"Modern WebSocket completion parameters could not serialize",
)
})?,
ProtocolEra::Legacy2024 => {
serde_json::to_value(params.into_legacy()?).map_err(|_| {
McpError::invalid_params(
"Exact legacy WebSocket completion parameters could not serialize",
)
})?
}
};
let object = parameters.as_object_mut().ok_or_else(|| {
McpError::internal_error("WebSocket completion parameters must remain an object")
})?;
object.insert(
"_meta".to_owned(),
serde_json::json!({ "progressToken": token }),
);
self.request_core_verb(cx, "completion/complete", parameters)
.await
}
async fn request_core_verb(
&mut self,
cx: &Cx,
method: &str,
parameters: serde_json::Value,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
match self.selected_protocol_era() {
ProtocolEra::Modern2026 => self.request_final_core(cx, method, parameters).await,
ProtocolEra::Legacy2024 => self.request_selected_core(cx, method, parameters).await,
}
}
fn websocket_list_parameters(cursor: Option<&str>) -> serde_json::Value {
list_catalog_wire_parameters(cursor, None, None)
}
/// Lists one page of tools through the negotiated WebSocket era.
pub async fn list_tools(&mut self, cx: &Cx, cursor: Option<&str>) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.list_tools_with_params(
cx,
ListToolsParams {
cursor: cursor.map(ToOwned::to_owned),
..ListToolsParams::default()
},
)
.await
}
/// Lists one page of tools with include/exclude tag filters.
pub async fn list_tools_with_params(
&mut self,
cx: &Cx,
params: ListToolsParams,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.request_core_verb(
cx,
"tools/list",
list_catalog_wire_parameters(
params.cursor.as_deref(),
params.include_tags.as_ref(),
params.exclude_tags.as_ref(),
),
)
.await
}
/// Lists one catalog page through the negotiated WebSocket era.
///
/// Callers supply the already-encoded list-parameter object so exact-2024
/// cursor identity (kind + include/exclude tags) can ride the same verb
/// as a bare cursor.
pub async fn list_catalog_page(
&mut self,
cx: &Cx,
method: &str,
parameters: serde_json::Value,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.request_core_verb(cx, method, parameters).await
}
/// Sends `ping` through the negotiated WebSocket era.
pub async fn ping(&mut self, cx: &Cx) -> McpResult<()>
where
IO: Send + 'static,
{
self.ping_with_optional_cancellation(cx, None).await
}
/// Sends `ping` under a caller-owned cancellation domain.
pub async fn ping_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
) -> McpResult<()>
where
IO: Send + 'static,
{
self.ping_with_optional_cancellation(cx, Some(cancellation))
.await
}
async fn ping_with_optional_cancellation(
&mut self,
cx: &Cx,
cancellation: Option<&McpRequestCancellation>,
) -> McpResult<()>
where
IO: Send + 'static,
{
if cx.checkpoint().is_err()
|| cancellation.is_some_and(McpRequestCancellation::is_cancel_requested)
{
return Err(McpError::request_cancelled());
}
if self.closed {
return Err(McpError::internal_error("WebSocket client is closed"));
}
let parameters = if self.selected_protocol_era() == ProtocolEra::Modern2026 {
self.with_modern_request_metadata(serde_json::json!({}))?
} else {
serde_json::json!({})
};
let request_id = self.allocate_request_id()?;
let received = self
.request_admitted_with_raw_result(
cx,
JsonRpcRequest::new("ping", Some(parameters), request_id),
cancellation,
)
.await?;
if let Some(error) = received.response.error {
return Err(json_rpc_error_to_mcp(error));
}
Ok(())
}
/// Configures the selected protocol era's log level behavior.
///
/// A modern session stores the complete RFC 5424 level and adds it as
/// `io.modelcontextprotocol/logLevel` metadata to every later request. It
/// never sends `logging/setLevel`.
pub fn set_log_level_typed(&mut self, level: LoggingLevel) -> McpResult<()>
where
IO: Send + 'static,
{
match self.selected_protocol_era() {
ProtocolEra::Modern2026 => {
self.final_log_level = Some(level);
Ok(())
}
ProtocolEra::Legacy2024 => Err(McpError::invalid_params(
"modern request logLevel metadata is only for MCP 2026-07-28",
)),
}
}
/// Configures one of the RFC 5424 severities supported by both protocol eras.
pub fn set_log_level(&mut self, level: LogLevel) -> McpResult<()>
where
IO: Send + 'static,
{
self.set_log_level_typed(final_log_level(level))
}
/// Sends exact-2024 `logging/setLevel` on this WebSocket session.
///
/// Modern sessions must use [`Self::set_log_level_typed`] instead.
pub async fn set_legacy_log_level(&mut self, cx: &Cx, level: LogLevel) -> McpResult<()>
where
IO: Send + 'static,
{
if self.selected_protocol_era() != ProtocolEra::Legacy2024 {
return Err(McpError::invalid_params(
"logging/setLevel is only for MCP 2024-11-05",
));
}
if cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
if self.closed {
return Err(McpError::internal_error("WebSocket client is closed"));
}
let parameters = serde_json::to_value(SetLogLevelParams { level }).map_err(|error| {
McpError::invalid_params(format!(
"WebSocket logging/setLevel parameters could not serialize: {error}"
))
})?;
let received = self
.request_with_raw_result(cx, "logging/setLevel", Some(parameters))
.await?;
if let Some(error) = received.response.error {
return Err(json_rpc_error_to_mcp(error));
}
Ok(())
}
/// Starts one exact-2024 `resources/subscribe` on this WebSocket session.
pub async fn subscribe_resource(&mut self, cx: &Cx, uri: &str) -> McpResult<()>
where
IO: Send + 'static,
{
self.request_legacy_empty(cx, "resources/subscribe", serde_json::json!({ "uri": uri }))
.await
}
/// Ends one exact-2024 `resources/unsubscribe` on this WebSocket session.
pub async fn unsubscribe_resource(&mut self, cx: &Cx, uri: &str) -> McpResult<()>
where
IO: Send + 'static,
{
self.request_legacy_empty(
cx,
"resources/unsubscribe",
serde_json::json!({ "uri": uri }),
)
.await
}
/// Sends exact-2024 `notifications/roots/list_changed` on this socket.
///
/// Modern sessions reject it: the legacy reverse-notification vocabulary
/// is not a final transport escape hatch. The client must have advertised
/// `roots.listChanged` during initialization.
pub async fn roots_list_changed(&mut self, cx: &Cx) -> McpResult<()>
where
IO: Send + 'static,
{
if cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
if self.closed {
return Err(McpError::internal_error("WebSocket client is closed"));
}
if self.selected_protocol_era() != ProtocolEra::Legacy2024 {
return Err(McpError::method_not_found(NOTIFICATIONS_ROOTS_LIST_CHANGED));
}
if !self
.session
.client_capabilities()
.roots
.as_ref()
.is_some_and(|roots| roots.list_changed)
{
return Err(McpError::invalid_request(
"MCP 2024-11-05 roots/list_changed requires advertised roots.listChanged",
));
}
let notification = JsonRpcMessage::Request(JsonRpcRequest::notification(
NOTIFICATIONS_ROOTS_LIST_CHANGED,
None,
));
if let Err(error) = self.send_message(cx, ¬ification).await {
return Err(self.terminal_transport_error(cx, error).await);
}
Ok(())
}
async fn request_legacy_empty(
&mut self,
cx: &Cx,
method: &str,
parameters: serde_json::Value,
) -> McpResult<()>
where
IO: Send + 'static,
{
if self.selected_protocol_era() != ProtocolEra::Legacy2024 {
return Err(McpError::invalid_params(format!(
"{method} is only for MCP 2024-11-05"
)));
}
let received = self
.request_with_raw_result(cx, method, Some(parameters))
.await?;
if let Some(error) = received.response.error {
return Err(json_rpc_error_to_mcp(error));
}
Ok(())
}
/// Lists one page of resources through the negotiated WebSocket era.
pub async fn list_resources(&mut self, cx: &Cx, cursor: Option<&str>) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.list_resources_with_params(
cx,
ListResourcesParams {
cursor: cursor.map(ToOwned::to_owned),
..ListResourcesParams::default()
},
)
.await
}
/// Lists one page of resources with include/exclude tag filters.
pub async fn list_resources_with_params(
&mut self,
cx: &Cx,
params: ListResourcesParams,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.request_core_verb(
cx,
"resources/list",
list_catalog_wire_parameters(
params.cursor.as_deref(),
params.include_tags.as_ref(),
params.exclude_tags.as_ref(),
),
)
.await
}
/// Lists one page of resource templates through the negotiated WebSocket era.
pub async fn list_resource_templates(
&mut self,
cx: &Cx,
cursor: Option<&str>,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.list_resource_templates_with_params(
cx,
ListResourceTemplatesParams {
cursor: cursor.map(ToOwned::to_owned),
..ListResourceTemplatesParams::default()
},
)
.await
}
/// Lists one page of resource templates with include/exclude tag filters.
pub async fn list_resource_templates_with_params(
&mut self,
cx: &Cx,
params: ListResourceTemplatesParams,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.request_core_verb(
cx,
"resources/templates/list",
list_catalog_wire_parameters(
params.cursor.as_deref(),
params.include_tags.as_ref(),
params.exclude_tags.as_ref(),
),
)
.await
}
/// Lists one page of prompts through the negotiated WebSocket era.
pub async fn list_prompts(&mut self, cx: &Cx, cursor: Option<&str>) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.list_prompts_with_params(
cx,
ListPromptsParams {
cursor: cursor.map(ToOwned::to_owned),
..ListPromptsParams::default()
},
)
.await
}
/// Lists one page of prompts with include/exclude tag filters.
pub async fn list_prompts_with_params(
&mut self,
cx: &Cx,
params: ListPromptsParams,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.request_core_verb(
cx,
"prompts/list",
list_catalog_wire_parameters(
params.cursor.as_deref(),
params.include_tags.as_ref(),
params.exclude_tags.as_ref(),
),
)
.await
}
fn websocket_prompt_parameters(
name: &str,
arguments: std::collections::HashMap<String, String>,
) -> McpResult<serde_json::Value> {
let mut parameters = serde_json::json!({ "name": name });
if !arguments.is_empty() {
let object = parameters.as_object_mut().ok_or_else(|| {
McpError::internal_error("WebSocket prompt parameters must remain an object")
})?;
object.insert(
"arguments".to_owned(),
serde_json::to_value(arguments).map_err(|_| {
McpError::internal_error("WebSocket prompt arguments could not serialize")
})?,
);
}
Ok(parameters)
}
/// Reads one resource through the negotiated WebSocket era.
///
/// Installed modern reverse handlers fulfill `input_required` the same way
/// as [`Self::call_tool`].
pub async fn read_resource(&mut self, cx: &Cx, uri: &str) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.follow_installed_mrtr(
cx,
None,
"resources/read",
serde_json::json!({ "uri": uri }),
)
.await
}
/// Reads one resource under a caller-owned cancellation domain.
///
/// Installed modern reverse handlers fulfill `input_required` the same way
/// as [`Self::call_tool`]. Cancellation is checked on every WebSocket
/// round.
pub async fn read_resource_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
uri: &str,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.follow_installed_mrtr(
cx,
Some(cancellation),
"resources/read",
serde_json::json!({ "uri": uri }),
)
.await
}
/// Reads one resource and admits request-scoped `notifications/progress`
/// for the supplied progress marker.
///
/// Drain those frames with [`Self::take_final_progress_notifications`].
pub async fn read_resource_with_progress_marker(
&mut self,
cx: &Cx,
uri: &str,
progress_marker: ProgressMarker,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
let token = serde_json::to_value(progress_marker).map_err(|_| {
McpError::internal_error("WebSocket progress token could not be encoded")
})?;
self.follow_installed_mrtr(
cx,
None,
"resources/read",
serde_json::json!({
"uri": uri,
"_meta": { "progressToken": token },
}),
)
.await
}
/// Gets one prompt through the negotiated WebSocket era.
///
/// Installed modern reverse handlers fulfill `input_required` the same way
/// as [`Self::call_tool`].
pub async fn get_prompt(
&mut self,
cx: &Cx,
name: &str,
arguments: std::collections::HashMap<String, String>,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.follow_installed_mrtr(
cx,
None,
"prompts/get",
Self::websocket_prompt_parameters(name, arguments)?,
)
.await
}
/// Gets one prompt under a caller-owned cancellation domain.
///
/// Installed modern reverse handlers fulfill `input_required` the same way
/// as [`Self::call_tool`]. Cancellation is checked on every WebSocket
/// round.
pub async fn get_prompt_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
name: &str,
arguments: std::collections::HashMap<String, String>,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.follow_installed_mrtr(
cx,
Some(cancellation),
"prompts/get",
Self::websocket_prompt_parameters(name, arguments)?,
)
.await
}
/// Gets one prompt and admits request-scoped `notifications/progress` for
/// the supplied progress marker.
///
/// Drain those frames with [`Self::take_final_progress_notifications`].
pub async fn get_prompt_with_progress_marker(
&mut self,
cx: &Cx,
name: &str,
arguments: std::collections::HashMap<String, String>,
progress_marker: ProgressMarker,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
let token = serde_json::to_value(progress_marker).map_err(|_| {
McpError::internal_error("WebSocket progress token could not be encoded")
})?;
let mut parameters = Self::websocket_prompt_parameters(name, arguments)?;
let object = parameters.as_object_mut().ok_or_else(|| {
McpError::internal_error("WebSocket prompt parameters must remain an object")
})?;
object.insert(
"_meta".to_owned(),
serde_json::json!({ "progressToken": token }),
);
self.follow_installed_mrtr(cx, None, "prompts/get", parameters)
.await
}
/// Calls one tool through the negotiated WebSocket era.
///
/// When modern reverse handlers are installed, a peer `input_required`
/// result is fulfilled locally and retried with `inputResponses`.
pub async fn call_tool(
&mut self,
cx: &Cx,
name: &str,
arguments: serde_json::Value,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.follow_installed_mrtr(
cx,
None,
"tools/call",
serde_json::json!({ "name": name, "arguments": arguments }),
)
.await
}
async fn request_core_verb_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
method: &str,
parameters: serde_json::Value,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
let era = self.selected_protocol_era();
let parameters = if era == ProtocolEra::Modern2026 {
self.with_modern_request_metadata(parameters)?
} else {
parameters
};
let request = CoreRequest::decode(era, method, Some(¶meters)).map_err(|_| {
McpError::invalid_params(
"WebSocket request parameters do not match the negotiated method contract",
)
})?;
if self.closed {
return Err(McpError::internal_error("WebSocket client is closed"));
}
let request_id = self.allocate_request_id()?;
let received = self
.request_admitted_with_raw_result(
cx,
JsonRpcRequest::new(method, Some(parameters), request_id),
Some(cancellation),
)
.await?;
if let Some(error) = received.response.error {
return Err(json_rpc_error_to_mcp(error));
}
let result = received.response.result.as_ref().ok_or_else(|| {
McpError::invalid_request("WebSocket successful response has no result")
})?;
let source = received.raw_result.as_deref().ok_or_else(|| {
McpError::invalid_request("WebSocket response has no admitted result source")
})?;
decode_core_result_from_source(&request, result, Some(source))
}
/// Lists one page of tools under a caller-owned cancellation domain.
///
/// A domain that is already cancelled rejects before allocating an ID or
/// writing a frame. After send, cancellation retires the correlated
/// response and emits `notifications/cancelled`. A blocked ingress wait
/// still belongs to the connection `Cx` until the next frame arrives.
pub async fn list_tools_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
cursor: Option<&str>,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.list_tools_with_params_and_cancellation(
cx,
cancellation,
ListToolsParams {
cursor: cursor.map(ToOwned::to_owned),
..ListToolsParams::default()
},
)
.await
}
/// Lists one tag-filtered tools page under a caller-owned cancellation domain.
pub async fn list_tools_with_params_and_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
params: ListToolsParams,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.request_core_verb_with_cancellation(
cx,
cancellation,
"tools/list",
list_catalog_wire_parameters(
params.cursor.as_deref(),
params.include_tags.as_ref(),
params.exclude_tags.as_ref(),
),
)
.await
}
/// Lists one page of resources under a caller-owned cancellation domain.
pub async fn list_resources_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
cursor: Option<&str>,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.list_resources_with_params_and_cancellation(
cx,
cancellation,
ListResourcesParams {
cursor: cursor.map(ToOwned::to_owned),
..ListResourcesParams::default()
},
)
.await
}
/// Lists one tag-filtered resources page under a caller-owned cancellation domain.
pub async fn list_resources_with_params_and_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
params: ListResourcesParams,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.request_core_verb_with_cancellation(
cx,
cancellation,
"resources/list",
list_catalog_wire_parameters(
params.cursor.as_deref(),
params.include_tags.as_ref(),
params.exclude_tags.as_ref(),
),
)
.await
}
/// Lists one page of resource templates under a caller-owned cancellation
/// domain.
pub async fn list_resource_templates_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
cursor: Option<&str>,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.list_resource_templates_with_params_and_cancellation(
cx,
cancellation,
ListResourceTemplatesParams {
cursor: cursor.map(ToOwned::to_owned),
..ListResourceTemplatesParams::default()
},
)
.await
}
/// Lists one tag-filtered templates page under a caller-owned cancellation domain.
pub async fn list_resource_templates_with_params_and_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
params: ListResourceTemplatesParams,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.request_core_verb_with_cancellation(
cx,
cancellation,
"resources/templates/list",
list_catalog_wire_parameters(
params.cursor.as_deref(),
params.include_tags.as_ref(),
params.exclude_tags.as_ref(),
),
)
.await
}
/// Lists one page of prompts under a caller-owned cancellation domain.
pub async fn list_prompts_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
cursor: Option<&str>,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.list_prompts_with_params_and_cancellation(
cx,
cancellation,
ListPromptsParams {
cursor: cursor.map(ToOwned::to_owned),
..ListPromptsParams::default()
},
)
.await
}
/// Lists one tag-filtered prompts page under a caller-owned cancellation domain.
pub async fn list_prompts_with_params_and_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
params: ListPromptsParams,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.request_core_verb_with_cancellation(
cx,
cancellation,
"prompts/list",
list_catalog_wire_parameters(
params.cursor.as_deref(),
params.include_tags.as_ref(),
params.exclude_tags.as_ref(),
),
)
.await
}
/// Calls one tool under a caller-owned cancellation domain.
///
/// Installed modern reverse handlers fulfill `input_required` the same way
/// as [`Self::call_tool`].
pub async fn call_tool_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
name: &str,
arguments: serde_json::Value,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.follow_installed_mrtr(
cx,
Some(cancellation),
"tools/call",
serde_json::json!({ "name": name, "arguments": arguments }),
)
.await
}
/// Calls one tool and admits request-scoped `notifications/progress` for
/// the supplied progress marker.
///
/// Drain those frames with [`Self::take_final_progress_notifications`].
pub async fn call_tool_with_progress_marker(
&mut self,
cx: &Cx,
name: &str,
arguments: serde_json::Value,
progress_marker: ProgressMarker,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
let token = serde_json::to_value(progress_marker).map_err(|_| {
McpError::internal_error("WebSocket progress token could not be encoded")
})?;
self.follow_installed_mrtr(
cx,
None,
"tools/call",
serde_json::json!({
"name": name,
"arguments": arguments,
"_meta": { "progressToken": token },
}),
)
.await
}
async fn follow_installed_mrtr(
&mut self,
cx: &Cx,
cancellation: Option<&McpRequestCancellation>,
method: &str,
original_parameters: serde_json::Value,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
let handlers = self
.reverse_request_handlers
.has_modern_handlers()
.then(|| self.reverse_request_handlers.clone());
let mut parameters = original_parameters.clone();
let mut rounds = 0_usize;
loop {
let result = match cancellation {
Some(cancellation) => {
self.request_core_verb_with_cancellation(cx, cancellation, method, parameters)
.await?
}
None => self.request_core_verb(cx, method, parameters).await?,
};
let Some(input_required) = mrtr_input_required_for_method(method, &result) else {
return Ok(result);
};
let Some(handlers) = handlers.as_ref() else {
return Ok(result);
};
if rounds >= MAX_MRTR_CONTINUATION_ROUNDS {
return Err(McpError::invalid_request(
"MRTR continuation-round limit exceeded",
));
}
rounds += 1;
let input_responses = handlers
.respond_to_input_required_async(cx, input_required)
.await?;
parameters = mrtr_retry_parameters(
original_parameters.clone(),
input_required,
input_responses,
)?;
}
}
/// Sends one admitted generic MCP 2026-07-28 extension request.
///
/// The extension registry and retained discovery must agree that `method`
/// is a client-to-server method before this client allocates an ID or sends
/// a WebSocket frame. Exact-2024 sessions reject at that same no-contact
/// boundary through the immutable session admission gate.
pub async fn request_final_extension(
&mut self,
cx: &Cx,
extension_id: &fastmcp_protocol::ExtensionId,
method: &str,
parameters: serde_json::Value,
) -> McpResult<serde_json::Value>
where
IO: Send + 'static,
{
self.session
.admit_final_extension_method(extension_id, method)?;
if cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
let parameters = self.with_modern_request_metadata(parameters)?;
let request_id = self.allocate_request_id()?;
let received = self
.request_admitted_with_raw_result(
cx,
JsonRpcRequest::new(method, Some(parameters), request_id),
None,
)
.await?;
if let Some(error) = received.response.error {
return Err(json_rpc_error_to_mcp(error));
}
let source = received.raw_result.ok_or_else(|| {
McpError::invalid_request(
"WebSocket final extension response has no admitted result source",
)
})?;
serde_json::from_str(&source).map_err(|_| {
McpError::invalid_request("WebSocket final extension response result is not valid JSON")
})
}
/// Calls a modern tool and follows bounded input-required MRTR rounds.
///
/// The supplied deadline covers every request in the operation; a retry
/// cannot restart the caller's absolute time budget.
pub async fn call_tool_with_mrtr_retry<F>(
&mut self,
cx: &Cx,
deadline: Instant,
name: &str,
arguments: serde_json::Value,
mut respond: F,
) -> McpResult<FinalCoreResult>
where
F: FnMut(&InputRequiredResult) -> McpResult<MrtrInputResponses>,
IO: Send + 'static,
{
self.require_modern("tools/call")?;
let limits =
MrtrDriverLimits::new(MAX_MRTR_CONTINUATION_ROUNDS, MAX_MRTR_TOTAL_INPUT_RESPONSES)?;
let mut driver = MrtrDriver::new(cx, deadline, limits)?;
let original_parameters = serde_json::json!({
"name": name,
"arguments": arguments,
});
let mut parameters = original_parameters.clone();
loop {
driver.before_request()?;
let result = self
.request_final_core(cx, "tools/call", parameters)
.await?;
let Some(input_required) = mrtr_input_required_for_method("tools/call", &result) else {
let CoreResult::Final(result) = result else {
return Err(McpError::invalid_request(
"Modern WebSocket MRTR received an exact-legacy result",
));
};
return require_terminal_websocket_mrtr_result("tools/call", result);
};
driver.begin_continuation()?;
let input_responses = respond(input_required)?;
let input_response_count = input_responses.len();
parameters = mrtr_retry_parameters(
original_parameters.clone(),
input_required,
input_responses,
)?;
driver.admit_input_responses(input_response_count)?;
}
}
/// Reads a modern resource and follows bounded input-required MRTR rounds.
///
/// The caller-owned deadline covers the initial read and every retry.
pub async fn read_resource_with_mrtr_retry<F>(
&mut self,
cx: &Cx,
deadline: Instant,
uri: &str,
mut respond: F,
) -> McpResult<FinalCoreResult>
where
F: FnMut(&InputRequiredResult) -> McpResult<MrtrInputResponses>,
IO: Send + 'static,
{
self.require_modern("resources/read")?;
let limits =
MrtrDriverLimits::new(MAX_MRTR_CONTINUATION_ROUNDS, MAX_MRTR_TOTAL_INPUT_RESPONSES)?;
let mut driver = MrtrDriver::new(cx, deadline, limits)?;
let original_parameters = serde_json::json!({ "uri": uri });
let mut parameters = original_parameters.clone();
loop {
driver.before_request()?;
let result = self
.request_final_core(cx, "resources/read", parameters)
.await?;
let Some(input_required) = mrtr_input_required_for_method("resources/read", &result)
else {
let CoreResult::Final(result) = result else {
return Err(McpError::invalid_request(
"Modern WebSocket MRTR received an exact-legacy result",
));
};
return require_terminal_websocket_mrtr_result("resources/read", result);
};
driver.begin_continuation()?;
let input_responses = respond(input_required)?;
let input_response_count = input_responses.len();
parameters = mrtr_retry_parameters(
original_parameters.clone(),
input_required,
input_responses,
)?;
driver.admit_input_responses(input_response_count)?;
}
}
/// Gets a modern prompt and follows bounded input-required MRTR rounds.
///
/// The caller-owned deadline covers the initial prompt request and every
/// retry.
pub async fn get_prompt_with_mrtr_retry<F>(
&mut self,
cx: &Cx,
deadline: Instant,
name: &str,
arguments: std::collections::HashMap<String, String>,
mut respond: F,
) -> McpResult<FinalCoreResult>
where
F: FnMut(&InputRequiredResult) -> McpResult<MrtrInputResponses>,
IO: Send + 'static,
{
self.require_modern("prompts/get")?;
let limits =
MrtrDriverLimits::new(MAX_MRTR_CONTINUATION_ROUNDS, MAX_MRTR_TOTAL_INPUT_RESPONSES)?;
let mut driver = MrtrDriver::new(cx, deadline, limits)?;
let original_parameters = serde_json::json!({
"name": name,
"arguments": arguments,
});
let mut parameters = original_parameters.clone();
loop {
driver.before_request()?;
let result = self
.request_final_core(cx, "prompts/get", parameters)
.await?;
let Some(input_required) = mrtr_input_required_for_method("prompts/get", &result)
else {
let CoreResult::Final(result) = result else {
return Err(McpError::invalid_request(
"Modern WebSocket MRTR received an exact-legacy result",
));
};
return require_terminal_websocket_mrtr_result("prompts/get", result);
};
driver.begin_continuation()?;
let input_responses = respond(input_required)?;
let input_response_count = input_responses.len();
parameters = mrtr_retry_parameters(
original_parameters.clone(),
input_required,
input_responses,
)?;
driver.admit_input_responses(input_response_count)?;
}
}
/// Collects a modern subscription stream through its terminal response.
///
/// The receive half remains the only ingress owner for the complete
/// operation, so acknowledgement, live notifications, and termination
/// cannot be consumed by an unrelated request waiter.
///
/// Prefer [`Self::open_subscriptions_listener`] plus
/// [`Self::next_subscription_event`] when this client must keep issuing
/// ordinary requests while the stream is live.
pub async fn listen_subscriptions_typed(
&mut self,
cx: &Cx,
notifications: SubscriptionFilter,
) -> McpResult<SubscriptionListenCollector>
where
IO: Send + 'static,
{
if cx.checkpoint().is_err() {
return Err(self.terminal_cancellation(cx).await);
}
if self.closed {
return Err(McpError::internal_error("WebSocket client is closed"));
}
if self.live_catalog_subscription.is_some() {
return Err(McpError::invalid_request(
"A final WebSocket catalog subscription is already active on this client",
));
}
#[cfg(feature = "tasks")]
if self.live_task_subscription.is_some() {
return Err(McpError::invalid_request(
"A final Tasks WebSocket subscription is already active on this client",
));
}
self.require_modern("subscriptions/listen")?;
#[cfg(feature = "tasks")]
if task_subscription_ids(¬ifications)
.map_err(|_| McpError::invalid_params("invalid Tasks subscription filter"))?
.is_some()
{
self.require_final_tasks_direction(
TASK_STATUS_NOTIFICATION,
ExtensionDirection::ServerToClient,
)?;
}
let parameters = serde_json::json!({
"notifications": notifications.clone(),
});
#[cfg(feature = "tasks")]
let parameters = if task_subscription_ids(¬ifications)
.map_err(|_| McpError::invalid_params("invalid Tasks subscription filter"))?
.is_some()
{
self.with_final_tasks_client_capability(parameters)?
} else {
self.with_modern_request_metadata(parameters)?
};
#[cfg(not(feature = "tasks"))]
let parameters = self.with_modern_request_metadata(parameters)?;
let request_id = self.allocate_request_id()?;
let request = JsonRpcRequest::new(
"subscriptions/listen",
Some(parameters.clone()),
request_id.clone(),
);
if let Err(error) = self
.send_message(cx, &JsonRpcMessage::Request(request))
.await
{
return Err(self.terminal_transport_error(cx, error).await);
}
let core_request = CoreRequest::decode(
ProtocolEra::Modern2026,
"subscriptions/listen",
Some(¶meters),
)
.map_err(|_| {
McpError::invalid_params("Modern WebSocket subscription parameters are invalid")
})?;
let mut accepted_filter = None;
let mut retained_notifications = Vec::new();
#[cfg(feature = "tasks")]
let mut task_notifications = Vec::new();
loop {
if let Err(error) = self.drain_completed_reverse_callbacks() {
return Err(self.terminal_callback_error(cx, error).await);
}
let frame = match self.recv_with_callback_terminal(cx, None).await {
Ok(frame) => frame,
Err(_) if self.reverse_callback_pool.terminal_error().is_some() => {
return Err(self
.terminal_callback_error(
cx,
self.reverse_callback_pool
.terminal_error()
.expect("terminal callback error was observed"),
)
.await);
}
Err(error) => return Err(self.terminal_transport_error(cx, error).await),
};
match frame.message() {
JsonRpcMessage::Response(response) => {
if self.discard_retired_websocket_response(response) {
continue;
}
if !response
.id
.as_ref()
.is_some_and(|response_id| response_id.correlates_with(&request_id))
{
return Err(self
.close_after_protocol_error(
cx,
"WebSocket subscription response ID does not match its listener",
)
.await);
}
if let Some(error) = response.error.clone() {
return Err(json_rpc_error_to_mcp(error));
}
let response = response.clone();
let source = raw_result_from_admitted_response(&response, frame, "WebSocket")?
.ok_or_else(|| McpError::invalid_request(
"WebSocket subscription terminal response has no admitted result source",
))?;
let result = decode_core_result_from_source(
&core_request,
response.result.as_ref().ok_or_else(|| {
McpError::invalid_request(
"WebSocket subscription response has no result",
)
})?,
Some(&source),
)?;
let CoreResult::Final(FinalCoreResult::SubscriptionsListen {
result: terminal,
subscription_id,
..
}) = result
else {
return Err(McpError::invalid_request(
"WebSocket subscription terminal result is not subscriptions/listen",
));
};
if !subscription_id.correlates_with(&request_id) {
return Err(self
.close_after_protocol_error(
cx,
"WebSocket subscription terminal ID does not match its listener",
)
.await);
}
let accepted_filter = accepted_filter.ok_or_else(|| {
McpError::invalid_request(
"WebSocket subscription terminated before acknowledgement",
)
})?;
return Ok(SubscriptionListenCollector {
subscription_id,
accepted_filter,
notifications: retained_notifications,
#[cfg(feature = "tasks")]
task_notifications,
terminal,
});
}
JsonRpcMessage::Request(request) => {
#[cfg(feature = "tasks")]
if request.id.is_none() && request.method == TASK_STATUS_NOTIFICATION {
let accepted = accepted_filter.as_ref().ok_or_else(|| {
McpError::invalid_request(
"WebSocket subscription received a Tasks event before acknowledgement",
)
})?;
let accepted_task_ids = task_subscription_ids(accepted).map_err(|_| {
McpError::invalid_request(
"WebSocket subscription received a Tasks event without an acknowledged Tasks filter",
)
})?.ok_or_else(|| McpError::invalid_request(
"WebSocket subscription received a Tasks event without an acknowledged Tasks filter",
))?;
let task_notification =
serde_json::from_value::<FinalTaskStatusNotification>(
serde_json::to_value(request).map_err(|_| {
McpError::invalid_request(
"WebSocket subscription received an invalid Tasks event",
)
})?,
)
.map_err(|_| {
McpError::invalid_request(
"WebSocket subscription received an invalid Tasks event",
)
})?;
let subscription_id = task_notification
.params
.meta
.as_ref()
.and_then(|metadata| metadata.get(FINAL_SUBSCRIPTION_ID_META_KEY))
.and_then(|value| {
serde_json::from_value::<RequestId>(value.clone()).ok()
});
if !subscription_id.as_ref().is_some_and(|subscription_id| {
subscription_id.correlates_with(&request_id)
}) {
return Err(self.close_after_protocol_error(
cx,
"WebSocket Tasks event subscription ID does not match the listener",
).await);
}
if !accepted_task_ids
.iter()
.any(|task_id| task_id == &task_notification.params.task.base().task_id)
{
return Err(self.close_after_protocol_error(
cx,
"WebSocket Tasks event taskId is outside the acknowledged filter",
).await);
}
task_notifications.push(task_notification);
continue;
}
let notification = ServerNotification::decode(request).map_err(|_| {
McpError::invalid_request(
"WebSocket subscription received an invalid server notification",
)
})?;
match notification {
ServerNotification::SubscriptionsAcknowledged(acknowledgement) => {
if accepted_filter.is_some() {
return Err(McpError::invalid_request(
"WebSocket subscription received a duplicate acknowledgement",
));
}
if validate_subscription_acknowledgement(
&request_id,
¬ifications,
&acknowledgement,
)
.is_err()
{
return Err(self
.close_after_protocol_error(
cx,
"WebSocket subscription acknowledgement is invalid for its listener",
)
.await);
}
accepted_filter = Some(acknowledgement.notifications);
}
notification => {
let accepted = accepted_filter.as_ref().ok_or_else(|| {
McpError::invalid_request(
"WebSocket subscription emitted a notification before acknowledgement",
)
})?;
validate_subscription_notification_filter(¬ification, accepted)?;
retained_notifications.push(notification);
}
}
}
}
}
}
/// Starts a real, incrementally driven final catalog subscription on this
/// WebSocket connection.
///
/// Unlike [`Self::listen_subscriptions_typed`], this does not collect the
/// stream to terminal completion. Call [`Self::next_subscription_event`]
/// to let this client keep sole ownership of ingress while exposing each
/// acknowledged catalog or resource-update event in arrival order. The
/// same client can still issue ordinary requests such as `tools/list`.
pub async fn open_subscriptions_listener(
&mut self,
cx: &Cx,
notifications: SubscriptionFilter,
) -> McpResult<()>
where
IO: Send + 'static,
{
if cx.checkpoint().is_err() {
return Err(self.terminal_cancellation(cx).await);
}
if self.closed {
return Err(McpError::internal_error("WebSocket client is closed"));
}
if self.live_catalog_subscription.is_some() {
return Err(McpError::invalid_request(
"A final WebSocket catalog subscription is already active on this client",
));
}
self.require_modern("subscriptions/listen")?;
#[cfg(feature = "tasks")]
if task_subscription_ids(¬ifications)
.map_err(|_| McpError::invalid_params("invalid Tasks subscription filter"))?
.is_some()
{
return Err(McpError::invalid_params(
"A live catalog subscription cannot include taskIds; use open_final_task_subscription_listener",
));
}
if !catalog_subscription_requested(¬ifications) {
return Err(McpError::invalid_params(
"A live catalog subscription requires tools, resources, or prompts list_changed or resourceSubscriptions",
));
}
let parameters = self.with_modern_request_metadata(serde_json::json!({
"notifications": notifications.clone(),
}))?;
let core_request = CoreRequest::decode(
ProtocolEra::Modern2026,
"subscriptions/listen",
Some(¶meters),
)
.map_err(|_| {
McpError::invalid_params("Modern WebSocket subscription parameters are invalid")
})?;
let request_id = self.allocate_request_id()?;
let request =
JsonRpcRequest::new("subscriptions/listen", Some(parameters), request_id.clone());
if let Err(error) = self
.send_message(cx, &JsonRpcMessage::Request(request))
.await
{
return Err(self.terminal_transport_error(cx, error).await);
}
self.live_catalog_subscription = Some(LiveWebSocketCatalogSubscription {
request_id,
core_request,
requested_filter: notifications,
accepted_filter: None,
acknowledgement_delivered: false,
pending_notifications: VecDeque::new(),
terminal_response: None,
});
Ok(())
}
/// Drives the sole WebSocket ingress reader until one live catalog
/// listener event is available.
///
/// Catalog and resource-update events admitted while another sequential
/// request (for example `tools/list`) is in flight are harvested from the
/// connection-level queue, so the same client can observe `list_changed`
/// without collecting this stream to terminal.
pub async fn next_subscription_event(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
) -> McpResult<StdioSubscriptionEvent>
where
IO: Send + 'static,
{
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
self.cancel_live_catalog_subscription(cx).await?;
return Err(McpError::request_cancelled());
}
loop {
if let Err(error) = self.harvest_live_catalog_subscription_notifications() {
return Err(self.close_after_protocol_error(cx, &error.message).await);
}
if let Some(event) = self.take_ready_catalog_subscription_event()? {
return Ok(event);
}
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
self.cancel_live_catalog_subscription(cx).await?;
return Err(McpError::request_cancelled());
}
if let Err(error) = self.drain_completed_reverse_callbacks() {
return Err(self.terminal_callback_error(cx, error).await);
}
let frame = match self.recv_with_callback_terminal(cx, None).await {
Ok(frame) => frame,
Err(_) if self.reverse_callback_pool.terminal_error().is_some() => {
return Err(self
.terminal_callback_error(
cx,
self.reverse_callback_pool
.terminal_error()
.expect("terminal callback error was observed"),
)
.await);
}
Err(error) => return Err(self.terminal_transport_error(cx, error).await),
};
match frame.message() {
JsonRpcMessage::Response(response) => {
if self.discard_retired_websocket_response(response) {
continue;
}
let response = response.clone();
let raw_result =
raw_result_from_admitted_response(&response, frame, "WebSocket")?;
match self.park_websocket_listen_terminal(response, raw_result) {
Ok(true) => {}
Ok(false) => {
return Err(self
.close_after_protocol_error(
cx,
"WebSocket catalog listener received a response for another request",
)
.await);
}
Err(error) => {
return Err(self.close_after_protocol_error(cx, &error.message).await);
}
}
}
JsonRpcMessage::Request(_) => {
if let Err(error) = self.handle_unsolicited_websocket_request(cx, &frame).await
{
return Err(self.terminal_callback_error(cx, error).await);
}
}
}
}
}
fn park_websocket_listen_terminal(
&mut self,
response: JsonRpcResponse,
raw_result: Option<String>,
) -> McpResult<bool> {
if self
.live_catalog_subscription
.as_ref()
.is_some_and(|subscription| {
response.id.as_ref().is_some_and(|response_id| {
response_id.correlates_with(&subscription.request_id)
})
})
{
let subscription = self.live_catalog_subscription.as_mut().ok_or_else(|| {
McpError::invalid_request("No live final catalog WebSocket subscription is active")
})?;
if subscription.terminal_response.is_some() {
return Err(McpError::invalid_request(
"WebSocket catalog listener received a duplicate terminal response",
));
}
subscription.terminal_response = Some((response, raw_result));
return Ok(true);
}
#[cfg(feature = "tasks")]
if self
.live_task_subscription
.as_ref()
.is_some_and(|subscription| {
response.id.as_ref().is_some_and(|response_id| {
response_id.correlates_with(&subscription.request_id)
})
})
{
let subscription = self.live_task_subscription.as_mut().ok_or_else(|| {
McpError::invalid_request("No live final Tasks WebSocket subscription is active")
})?;
if subscription.terminal_response.is_some() {
return Err(McpError::invalid_request(
"WebSocket Tasks listener received a duplicate terminal response",
));
}
subscription.terminal_response = Some((response, raw_result));
return Ok(true);
}
Ok(false)
}
fn harvest_live_catalog_subscription_notifications(&mut self) -> McpResult<()> {
if self.live_catalog_subscription.is_none() {
return Ok(());
}
let queued: Vec<ServerNotification> = self.final_server_notifications.drain(..).collect();
let mut remainder = VecDeque::new();
let mut harvest_error = None;
for notification in queued {
if harvest_error.is_some() {
remainder.push_back(notification);
continue;
}
let Some(subscription) = self.live_catalog_subscription.as_mut() else {
remainder.push_back(notification);
continue;
};
match notification {
ServerNotification::SubscriptionsAcknowledged(acknowledgement) => {
if subscription_acknowledgement_is_foreign(
&subscription.request_id,
&acknowledgement,
) {
remainder.push_back(ServerNotification::SubscriptionsAcknowledged(
acknowledgement,
));
continue;
}
if subscription.accepted_filter.is_some() {
harvest_error = Some(subscription_listener_protocol_error(
"Subscription listener received a duplicate acknowledgement",
));
continue;
}
if let Err(error) = validate_subscription_acknowledgement(
&subscription.request_id,
&subscription.requested_filter,
&acknowledgement,
) {
harvest_error = Some(error);
continue;
}
subscription.accepted_filter = Some(acknowledgement.notifications);
}
notification @ (ServerNotification::ResourcesListChanged(_)
| ServerNotification::ToolsListChanged(_)
| ServerNotification::PromptsListChanged(_)
| ServerNotification::ResourceUpdated(_)) => {
let Some(accepted_filter) = subscription.accepted_filter.as_ref() else {
harvest_error = Some(subscription_listener_protocol_error(
"Subscription listener received an event before acknowledgement",
));
continue;
};
if let Err(error) =
validate_subscription_notification_filter(¬ification, accepted_filter)
{
harvest_error = Some(error);
continue;
}
if subscription.pending_notifications.len()
>= MAX_QUEUED_FINAL_SERVER_NOTIFICATIONS
{
harvest_error = Some(McpError::invalid_request(
FINAL_SERVER_NOTIFICATION_QUEUE_OVERFLOW_ERROR,
));
continue;
}
subscription.pending_notifications.push_back(notification);
}
other => remainder.push_back(other),
}
}
self.final_server_notifications = remainder;
if let Some(error) = harvest_error {
self.live_catalog_subscription = None;
return Err(error);
}
Ok(())
}
fn take_ready_catalog_subscription_event(
&mut self,
) -> McpResult<Option<StdioSubscriptionEvent>> {
// Function-local drain state; the wide terminal variant never leaves
// this call frame, so boxing only adds indirection.
#[allow(clippy::large_enum_variant)]
enum ReadyCatalog {
Event(StdioSubscriptionEvent),
Terminal {
response: JsonRpcResponse,
raw_result: Option<String>,
request_id: RequestId,
core_request: CoreRequest,
accepted_filter: Option<SubscriptionFilter>,
},
Waiting,
}
let ready = {
let subscription = self.live_catalog_subscription.as_mut().ok_or_else(|| {
McpError::invalid_request("No live final catalog WebSocket subscription is active")
})?;
if !subscription.acknowledgement_delivered
&& let Some(acknowledged) = subscription.accepted_filter.clone()
{
subscription.acknowledgement_delivered = true;
ReadyCatalog::Event(StdioSubscriptionEvent::Acknowledged(acknowledged))
} else if let Some(notification) = subscription.pending_notifications.pop_front() {
ReadyCatalog::Event(StdioSubscriptionEvent::Notification(notification))
} else if let Some((response, raw_result)) = subscription.terminal_response.take() {
ReadyCatalog::Terminal {
response,
raw_result,
request_id: subscription.request_id.clone(),
core_request: subscription.core_request.clone(),
accepted_filter: subscription.accepted_filter.clone(),
}
} else {
ReadyCatalog::Waiting
}
};
match ready {
ReadyCatalog::Event(event) => Ok(Some(event)),
ReadyCatalog::Waiting => Ok(None),
ReadyCatalog::Terminal {
response,
raw_result,
request_id,
core_request,
accepted_filter,
} => {
let Some(raw_result) = raw_result.as_deref() else {
self.live_catalog_subscription = None;
return Err(McpError::invalid_request(
"WebSocket subscriptions/listen response lost its admitted result source",
));
};
if let Some(error) = response.error.clone() {
self.live_catalog_subscription = None;
return Err(json_rpc_error_to_mcp(error));
}
let result = match decode_core_result_from_source(
&core_request,
response.result.as_ref().ok_or_else(|| {
McpError::invalid_request(
"WebSocket subscription terminal response has no result",
)
})?,
Some(raw_result),
) {
Ok(result) => result,
Err(error) => {
self.live_catalog_subscription = None;
return Err(error);
}
};
let CoreResult::Final(FinalCoreResult::SubscriptionsListen {
subscription_id, ..
}) = result
else {
self.live_catalog_subscription = None;
return Err(McpError::invalid_request(
"WebSocket subscription terminal result is not subscriptions/listen",
));
};
if !subscription_id.correlates_with(&request_id) {
self.live_catalog_subscription = None;
return Err(McpError::invalid_request(
"WebSocket subscription terminal ID does not match its request",
));
}
if accepted_filter.is_none() {
self.live_catalog_subscription = None;
return Err(McpError::invalid_request(
"WebSocket subscription terminated before acknowledgement",
));
}
self.live_catalog_subscription = None;
Ok(Some(StdioSubscriptionEvent::Terminal))
}
}
}
async fn cancel_live_catalog_subscription(&mut self, cx: &Cx) -> McpResult<()>
where
IO: Send + 'static,
{
let request_id = self
.live_catalog_subscription
.as_ref()
.ok_or_else(|| {
McpError::invalid_request("No live final catalog WebSocket subscription is active")
})?
.request_id
.clone();
let request_key = request_id.correlation_key().map_err(|_| {
McpError::invalid_request("WebSocket catalog listener request ID is not correlatable")
})?;
if self.retired_response_keys.len() >= MAX_QUEUED_WEBSOCKET_CANCELLED_RESPONSE_KEYS {
self.live_catalog_subscription = None;
return Err(self
.close_after_protocol_error(
cx,
"WebSocket cancelled-response tombstone capacity exceeded",
)
.await);
}
let control = CancellationWireMessage::Modern2026 {
sender: CancellationSender::Client,
params: FinalCancelledNotificationParams {
request_id,
reason: None,
meta: None,
additional: BTreeMap::default(),
},
}
.encode()
.map(JsonRpcMessage::Request)
.map_err(|error| {
McpError::invalid_params(format!("invalid WebSocket cancellation: {error}"))
})?;
self.retired_response_keys.push_back(request_key);
if let Err(error) = self.send_message(cx, &control).await {
return Err(self.terminal_transport_error(cx, error).await);
}
self.live_catalog_subscription = None;
Ok(())
}
/// Starts a real, incrementally driven official Tasks subscription on this
/// WebSocket connection.
///
/// Catalog [`Self::open_subscriptions_listener`] refuses `taskIds`. Call
/// [`Self::next_final_task_subscription_event`] so this client can keep
/// issuing `tasks/get` / `tasks/cancel` while draining status updates.
#[cfg(feature = "tasks")]
pub async fn open_final_task_subscription_listener(
&mut self,
cx: &Cx,
notifications: SubscriptionFilter,
) -> McpResult<()>
where
IO: Send + 'static,
{
if cx.checkpoint().is_err() {
return Err(self.terminal_cancellation(cx).await);
}
if self.closed {
return Err(McpError::internal_error("WebSocket client is closed"));
}
if self.live_task_subscription.is_some() {
return Err(McpError::invalid_request(
"A final Tasks WebSocket subscription is already active on this client",
));
}
self.require_modern("subscriptions/listen")?;
if task_subscription_ids(¬ifications)
.map_err(|_| McpError::invalid_params("invalid Tasks subscription filter"))?
.is_none()
{
return Err(McpError::invalid_params(
"A live final Tasks subscription requires taskIds",
));
}
self.require_final_tasks_direction(
TASK_STATUS_NOTIFICATION,
ExtensionDirection::ServerToClient,
)?;
let parameters = self.with_final_tasks_client_capability(serde_json::json!({
"notifications": notifications.clone(),
}))?;
let core_request = CoreRequest::decode(
ProtocolEra::Modern2026,
"subscriptions/listen",
Some(¶meters),
)
.map_err(|_| {
McpError::invalid_params("Modern WebSocket subscription parameters are invalid")
})?;
let request_id = self.allocate_request_id()?;
let request =
JsonRpcRequest::new("subscriptions/listen", Some(parameters), request_id.clone());
if let Err(error) = self
.send_message(cx, &JsonRpcMessage::Request(request))
.await
{
return Err(self.terminal_transport_error(cx, error).await);
}
self.live_task_subscription = Some(LiveWebSocketTaskSubscription {
request_id,
core_request,
requested_filter: notifications,
accepted_filter: None,
acknowledgement_delivered: false,
pending_notifications: VecDeque::new(),
terminal_response: None,
});
Ok(())
}
/// Drives the sole WebSocket ingress reader until one live Tasks listener
/// event is available.
///
/// Status updates admitted while another sequential request (for example
/// `tasks/cancel`) is in flight are harvested from the connection-level
/// queue, so the same client can observe `Cancelled` without collecting
/// this stream to terminal.
#[cfg(feature = "tasks")]
pub async fn next_final_task_subscription_event(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
) -> McpResult<StdioTaskSubscriptionEvent>
where
IO: Send + 'static,
{
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
self.cancel_live_task_subscription(cx).await?;
return Err(McpError::request_cancelled());
}
loop {
if let Err(error) = self.harvest_live_task_subscription_notifications() {
return Err(self.close_after_protocol_error(cx, &error.message).await);
}
if let Some(event) = self.take_ready_task_subscription_event()? {
return Ok(event);
}
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
self.cancel_live_task_subscription(cx).await?;
return Err(McpError::request_cancelled());
}
if let Err(error) = self.drain_completed_reverse_callbacks() {
return Err(self.terminal_callback_error(cx, error).await);
}
let frame = match self.recv_with_callback_terminal(cx, None).await {
Ok(frame) => frame,
Err(_) if self.reverse_callback_pool.terminal_error().is_some() => {
return Err(self
.terminal_callback_error(
cx,
self.reverse_callback_pool
.terminal_error()
.expect("terminal callback error was observed"),
)
.await);
}
Err(error) => return Err(self.terminal_transport_error(cx, error).await),
};
match frame.message() {
JsonRpcMessage::Response(response) => {
if self.discard_retired_websocket_response(response) {
continue;
}
let response = response.clone();
let raw_result =
raw_result_from_admitted_response(&response, frame, "WebSocket")?;
match self.park_websocket_listen_terminal(response, raw_result) {
Ok(true) => {}
Ok(false) => {
return Err(self
.close_after_protocol_error(
cx,
"WebSocket Tasks listener received a response for another request",
)
.await);
}
Err(error) => {
return Err(self.close_after_protocol_error(cx, &error.message).await);
}
}
}
JsonRpcMessage::Request(_) => {
if let Err(error) = self.handle_unsolicited_websocket_request(cx, &frame).await
{
return Err(self.terminal_callback_error(cx, error).await);
}
}
}
}
}
#[cfg(feature = "tasks")]
fn retain_live_task_status_notification(
&mut self,
frame: &ReceivedTransportFrame,
) -> McpResult<bool> {
let JsonRpcMessage::Request(request) = frame.message() else {
return Ok(false);
};
if request.id.is_some() || request.method != TASK_STATUS_NOTIFICATION {
return Ok(false);
}
let Some(subscription) = self.live_task_subscription.as_mut() else {
return Ok(false);
};
let accepted = subscription.accepted_filter.as_ref().ok_or_else(|| {
McpError::invalid_request(
"WebSocket subscription received a Tasks event before acknowledgement",
)
})?;
let accepted_task_ids = task_subscription_ids(accepted)
.map_err(|_| {
McpError::invalid_request(
"WebSocket subscription received a Tasks event without an acknowledged Tasks filter",
)
})?
.ok_or_else(|| {
McpError::invalid_request(
"WebSocket subscription received a Tasks event without an acknowledged Tasks filter",
)
})?;
let task_notification = serde_json::from_value::<FinalTaskStatusNotification>(
serde_json::to_value(request).map_err(|_| {
McpError::invalid_request("WebSocket subscription received an invalid Tasks event")
})?,
)
.map_err(|_| {
McpError::invalid_request("WebSocket subscription received an invalid Tasks event")
})?;
let subscription_id = task_notification
.params
.meta
.as_ref()
.and_then(|metadata| metadata.get(FINAL_SUBSCRIPTION_ID_META_KEY))
.and_then(|value| serde_json::from_value::<RequestId>(value.clone()).ok());
if !subscription_id.as_ref().is_some_and(|subscription_id| {
subscription_id.correlates_with(&subscription.request_id)
}) {
return Err(McpError::invalid_request(
"WebSocket Tasks event subscription ID does not match the listener",
));
}
if !accepted_task_ids
.iter()
.any(|task_id| task_id == &task_notification.params.task.base().task_id)
{
return Err(McpError::invalid_request(
"WebSocket Tasks event taskId is outside the acknowledged filter",
));
}
if subscription.pending_notifications.len() >= MAX_QUEUED_FINAL_SERVER_NOTIFICATIONS {
return Err(McpError::invalid_request(
FINAL_SERVER_NOTIFICATION_QUEUE_OVERFLOW_ERROR,
));
}
subscription
.pending_notifications
.push_back(task_notification);
Ok(true)
}
#[cfg(feature = "tasks")]
fn harvest_live_task_subscription_notifications(&mut self) -> McpResult<()> {
if self.live_task_subscription.is_none() {
return Ok(());
}
let queued: Vec<ServerNotification> = self.final_server_notifications.drain(..).collect();
let mut remainder = VecDeque::new();
let mut harvest_error = None;
for notification in queued {
if harvest_error.is_some() {
remainder.push_back(notification);
continue;
}
let Some(subscription) = self.live_task_subscription.as_mut() else {
remainder.push_back(notification);
continue;
};
match notification {
ServerNotification::SubscriptionsAcknowledged(acknowledgement) => {
if subscription_acknowledgement_is_foreign(
&subscription.request_id,
&acknowledgement,
) {
remainder.push_back(ServerNotification::SubscriptionsAcknowledged(
acknowledgement,
));
continue;
}
if subscription.accepted_filter.is_some() {
harvest_error = Some(subscription_listener_protocol_error(
"Subscription listener received a duplicate acknowledgement",
));
continue;
}
if let Err(error) = validate_subscription_acknowledgement(
&subscription.request_id,
&subscription.requested_filter,
&acknowledgement,
) {
harvest_error = Some(error);
continue;
}
subscription.accepted_filter = Some(acknowledgement.notifications);
}
other => remainder.push_back(other),
}
}
self.final_server_notifications = remainder;
if let Some(error) = harvest_error {
self.live_task_subscription = None;
return Err(error);
}
Ok(())
}
#[cfg(feature = "tasks")]
fn take_ready_task_subscription_event(
&mut self,
) -> McpResult<Option<StdioTaskSubscriptionEvent>> {
enum ReadyTask {
Event(StdioTaskSubscriptionEvent),
Terminal {
response: JsonRpcResponse,
raw_result: Option<String>,
request_id: RequestId,
core_request: CoreRequest,
accepted_filter: Option<SubscriptionFilter>,
},
Waiting,
}
let ready = {
let subscription = self.live_task_subscription.as_mut().ok_or_else(|| {
McpError::invalid_request("No live final Tasks WebSocket subscription is active")
})?;
if !subscription.acknowledgement_delivered
&& let Some(acknowledged) = subscription.accepted_filter.clone()
{
subscription.acknowledgement_delivered = true;
ReadyTask::Event(StdioTaskSubscriptionEvent::Acknowledged(acknowledged))
} else if let Some(notification) = subscription.pending_notifications.pop_front() {
ReadyTask::Event(StdioTaskSubscriptionEvent::Notification(notification))
} else if let Some((response, raw_result)) = subscription.terminal_response.take() {
ReadyTask::Terminal {
response,
raw_result,
request_id: subscription.request_id.clone(),
core_request: subscription.core_request.clone(),
accepted_filter: subscription.accepted_filter.clone(),
}
} else {
ReadyTask::Waiting
}
};
match ready {
ReadyTask::Event(event) => Ok(Some(event)),
ReadyTask::Waiting => Ok(None),
ReadyTask::Terminal {
response,
raw_result,
request_id,
core_request,
accepted_filter,
} => {
let Some(raw_result) = raw_result.as_deref() else {
self.live_task_subscription = None;
return Err(McpError::invalid_request(
"WebSocket subscriptions/listen response lost its admitted result source",
));
};
if let Some(error) = response.error.clone() {
self.live_task_subscription = None;
return Err(json_rpc_error_to_mcp(error));
}
let result = match decode_core_result_from_source(
&core_request,
response.result.as_ref().ok_or_else(|| {
McpError::invalid_request(
"WebSocket subscription terminal response has no result",
)
})?,
Some(raw_result),
) {
Ok(result) => result,
Err(error) => {
self.live_task_subscription = None;
return Err(error);
}
};
let CoreResult::Final(FinalCoreResult::SubscriptionsListen {
subscription_id, ..
}) = result
else {
self.live_task_subscription = None;
return Err(McpError::invalid_request(
"WebSocket subscription terminal result is not subscriptions/listen",
));
};
if !subscription_id.correlates_with(&request_id) {
self.live_task_subscription = None;
return Err(McpError::invalid_request(
"WebSocket subscription terminal ID does not match its request",
));
}
if accepted_filter.is_none() {
self.live_task_subscription = None;
return Err(McpError::invalid_request(
"WebSocket subscription terminated before acknowledgement",
));
}
self.live_task_subscription = None;
Ok(Some(StdioTaskSubscriptionEvent::Terminal))
}
}
}
#[cfg(feature = "tasks")]
async fn cancel_live_task_subscription(&mut self, cx: &Cx) -> McpResult<()>
where
IO: Send + 'static,
{
let request_id = self
.live_task_subscription
.as_ref()
.ok_or_else(|| {
McpError::invalid_request("No live final Tasks WebSocket subscription is active")
})?
.request_id
.clone();
let request_key = request_id.correlation_key().map_err(|_| {
McpError::invalid_request("WebSocket Tasks listener request ID is not correlatable")
})?;
if self.retired_response_keys.len() >= MAX_QUEUED_WEBSOCKET_CANCELLED_RESPONSE_KEYS {
self.live_task_subscription = None;
return Err(self
.close_after_protocol_error(
cx,
"WebSocket cancelled-response tombstone capacity exceeded",
)
.await);
}
let control = CancellationWireMessage::Modern2026 {
sender: CancellationSender::Client,
params: FinalCancelledNotificationParams {
request_id,
reason: None,
meta: None,
additional: BTreeMap::default(),
},
}
.encode()
.map(JsonRpcMessage::Request)
.map_err(|error| {
McpError::invalid_params(format!("invalid WebSocket cancellation: {error}"))
})?;
self.retired_response_keys.push_back(request_key);
if let Err(error) = self.send_message(cx, &control).await {
return Err(self.terminal_transport_error(cx, error).await);
}
self.live_task_subscription = None;
Ok(())
}
/// Calls a Tasks-capable modern tool without projecting its result algebra.
#[cfg(feature = "tasks")]
pub async fn call_tool_final_outcome(
&mut self,
cx: &Cx,
name: &str,
arguments: serde_json::Value,
) -> McpResult<FinalToolCallOutcome>
where
IO: Send + 'static,
{
self.require_final_tasks_result(OFFICIAL_TASKS_RESULT_DISCRIMINATOR)?;
let parameters = self.with_final_tasks_client_capability(serde_json::json!({
"name": name,
"arguments": arguments,
}))?;
match self
.request_final_core_prepared(cx, "tools/call", parameters)
.await?
{
CoreResult::Final(FinalCoreResult::ToolsCall { result, .. }) => {
Ok(FinalToolCallOutcome::Complete(result))
}
CoreResult::Final(FinalCoreResult::ToolsCallTask { result }) => {
Ok(FinalToolCallOutcome::Task(result))
}
CoreResult::Final(FinalCoreResult::ToolsCallInputRequired { result, .. }) => {
Ok(FinalToolCallOutcome::InputRequired(result))
}
_ => Err(unexpected_convenience_result("tools/call")),
}
}
/// Calls a Tasks-capable modern tool while stamping the caller's progress token.
#[cfg(feature = "tasks")]
pub async fn call_tool_final_outcome_with_progress_marker(
&mut self,
cx: &Cx,
name: &str,
arguments: serde_json::Value,
progress_marker: ProgressMarker,
) -> McpResult<FinalToolCallOutcome>
where
IO: Send + 'static,
{
self.require_final_tasks_result(OFFICIAL_TASKS_RESULT_DISCRIMINATOR)?;
let parameters = self.with_final_tasks_client_capability(serde_json::json!({
"name": name,
"arguments": arguments,
"_meta": { "progressToken": progress_marker },
}))?;
match self
.request_final_core_prepared(cx, "tools/call", parameters)
.await?
{
CoreResult::Final(FinalCoreResult::ToolsCall { result, .. }) => {
Ok(FinalToolCallOutcome::Complete(result))
}
CoreResult::Final(FinalCoreResult::ToolsCallTask { result }) => {
Ok(FinalToolCallOutcome::Task(result))
}
CoreResult::Final(FinalCoreResult::ToolsCallInputRequired { result, .. }) => {
Ok(FinalToolCallOutcome::InputRequired(result))
}
_ => Err(unexpected_convenience_result("tools/call")),
}
}
/// Reads one task through the negotiated official Tasks extension.
#[cfg(feature = "tasks")]
pub async fn get_task_final(
&mut self,
cx: &Cx,
task_id: FinalTaskId,
) -> McpResult<FinalGetTaskResult>
where
IO: Send + 'static,
{
if self.closed {
return Err(McpError::internal_error("WebSocket client is closed"));
}
self.require_final_tasks_method(TASK_GET)?;
let result: FinalGetTaskResult = self
.request_final_tasks(
cx,
TASK_GET,
FinalGetTaskParams {
request: self.final_task_request_meta()?,
task_id: task_id.clone(),
},
)
.await?;
if result.task.base().task_id != task_id {
return Err(self
.close_after_protocol_error(
cx,
"WebSocket tasks/get response taskId does not match its request",
)
.await);
}
Ok(result)
}
/// Supplies typed inputs for one retained input-required task.
#[cfg(feature = "tasks")]
pub async fn update_task_final(
&mut self,
cx: &Cx,
task: &FinalTask,
input_responses: FinalTaskInputResponses,
) -> McpResult<FinalUpdateTaskResult>
where
IO: Send + 'static,
{
self.require_final_tasks_method(TASK_UPDATE)?;
let FinalTask::InputRequired {
base,
input_requests,
} = task
else {
return Err(McpError::invalid_params(
"tasks/update requires an input_required final task",
));
};
TaskInputLedger::from_requests(input_requests)
.map_err(|_| McpError::invalid_params("Final task input requests are invalid"))?
.validate_responses(&input_responses)
.map_err(|_| {
McpError::invalid_params(
"tasks/update inputResponses do not match the retained task input requests",
)
})?;
self.request_final_tasks(
cx,
TASK_UPDATE,
FinalUpdateTaskParams {
request: self.final_task_request_meta()?,
task_id: base.task_id.clone(),
input_responses,
},
)
.await
}
/// Requests cancellation through the negotiated official Tasks extension.
#[cfg(feature = "tasks")]
pub async fn cancel_task_final(
&mut self,
cx: &Cx,
task_id: FinalTaskId,
) -> McpResult<FinalCancelTaskResult>
where
IO: Send + 'static,
{
self.require_final_tasks_method(TASK_CANCEL)?;
self.request_final_tasks(
cx,
TASK_CANCEL,
FinalCancelTaskParams {
request: self.final_task_request_meta()?,
task_id,
},
)
.await
}
fn require_modern(&self, method: &str) -> McpResult<()> {
if self.selected_protocol_era() == ProtocolEra::Modern2026 {
Ok(())
} else {
Err(McpError::invalid_params(format!(
"{method} is available only for MCP 2026-07-28 WebSocket sessions",
)))
}
}
fn with_modern_request_metadata(
&self,
mut params: serde_json::Value,
) -> McpResult<serde_json::Value> {
self.require_modern("modern request metadata")?;
let object = params.as_object_mut().ok_or_else(|| {
McpError::invalid_params("Modern WebSocket requests require object parameters")
})?;
let mut metadata = serde_json::to_value(FinalRequestMeta {
protocol_version: MODERN_PROTOCOL_VERSION.to_owned(),
client_capabilities: self.session.client_capabilities().clone(),
client_info: Some(self.session.modern_client_implementation()),
additional_metadata: BTreeMap::new(),
})
.map_err(|_| McpError::internal_error("Modern WebSocket request metadata is invalid"))?;
let generated = metadata.as_object_mut().ok_or_else(|| {
McpError::internal_error("Modern WebSocket request metadata is not an object")
})?;
if let Some(configured_extensions) = self.session.client_extension_wire_settings() {
let capabilities = generated
.get_mut(FINAL_CLIENT_CAPABILITIES_META_KEY)
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
McpError::internal_error(
"Modern WebSocket request metadata omitted client capabilities",
)
})?;
let extensions = capabilities
.entry("extensions")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
.as_object_mut()
.ok_or_else(|| {
McpError::internal_error("Modern WebSocket client extensions must be an object")
})?;
for (extension_id, settings) in configured_extensions {
extensions.insert(extension_id, settings);
}
}
#[cfg(feature = "tasks")]
insert_negotiated_tasks_client_extension(generated, self.session.server_discovery())?;
#[cfg(feature = "apps")]
let advertise_mcp_apps = !self.session.generic_mcp_apps_configured()
&& (self.session.server_discovery().is_none() || self.session.mcp_apps_active());
#[cfg(not(feature = "apps"))]
let advertise_mcp_apps = false;
if let Some(settings) = advertise_mcp_apps
.then_some(self.session.mcp_apps_settings())
.flatten()
{
let capabilities = generated
.get_mut(FINAL_CLIENT_CAPABILITIES_META_KEY)
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
McpError::internal_error(
"Modern WebSocket request metadata omitted client capabilities",
)
})?;
let extensions = capabilities
.entry("extensions")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
.as_object_mut()
.ok_or_else(|| {
McpError::internal_error("Modern WebSocket client extensions must be an object")
})?;
extensions.insert(
fastmcp_protocol::extensions::OFFICIAL_MCP_APPS_EXTENSION_ID.to_owned(),
settings.to_extension_settings().into_value(),
);
}
let inbound_log_level = object.get("_meta").and_then(|meta| {
meta.as_object()
.and_then(|meta| meta.get(FINAL_LOG_LEVEL_META_KEY).cloned())
});
let inbound_capabilities = object.get("_meta").and_then(|meta| {
meta.as_object()
.and_then(|meta| meta.get(FINAL_CLIENT_CAPABILITIES_META_KEY).cloned())
});
if let Some(existing) = object.remove("_meta") {
let existing = existing.as_object().ok_or_else(|| {
McpError::invalid_params("Modern WebSocket request metadata must be an object")
})?;
for (key, value) in existing {
generated
.entry(key.clone())
.or_insert_with(|| value.clone());
}
}
insert_final_request_log_level(generated, self.final_log_level)?;
if let Some(inbound_log_level) = inbound_log_level {
generated.insert(FINAL_LOG_LEVEL_META_KEY.to_owned(), inbound_log_level);
}
if let Some(inbound) = inbound_capabilities.and_then(|value| value.as_object().cloned()) {
if let Some(capabilities) = generated
.get_mut(FINAL_CLIENT_CAPABILITIES_META_KEY)
.and_then(serde_json::Value::as_object_mut)
{
for key in ["sampling", "elicitation", "roots"] {
match inbound.get(key) {
Some(value) => {
capabilities.insert(key.to_owned(), value.clone());
}
None => {
capabilities.remove(key);
}
}
}
}
}
object.insert("_meta".to_owned(), metadata);
Ok(params)
}
async fn request_final_core(
&mut self,
cx: &Cx,
method: &str,
params: serde_json::Value,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.require_modern(method)?;
let params = self.with_modern_request_metadata(params)?;
self.request_final_core_prepared(cx, method, params).await
}
async fn request_selected_core(
&mut self,
cx: &Cx,
method: &str,
params: serde_json::Value,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
let era = self.selected_protocol_era();
let request = CoreRequest::decode(era, method, Some(¶ms)).map_err(|_| {
McpError::invalid_params(
"WebSocket completion parameters do not match the negotiated method contract",
)
})?;
let received = self
.request_with_raw_result(cx, method, Some(params))
.await?;
if let Some(error) = received.response.error {
return Err(json_rpc_error_to_mcp(error));
}
let result = received.response.result.as_ref().ok_or_else(|| {
McpError::invalid_request("WebSocket successful response has no result")
})?;
let source = received.raw_result.as_deref().ok_or_else(|| {
McpError::invalid_request("WebSocket response has no admitted result source")
})?;
decode_core_result_from_source(&request, result, Some(source))
}
/// Decodes and sends already-decorated final parameters without replacing
/// their negotiated extension capability metadata.
async fn request_final_core_prepared(
&mut self,
cx: &Cx,
method: &str,
params: serde_json::Value,
) -> McpResult<CoreResult>
where
IO: Send + 'static,
{
self.require_modern(method)?;
let request =
CoreRequest::decode(ProtocolEra::Modern2026, method, Some(¶ms)).map_err(|_| {
McpError::invalid_params(
"Modern WebSocket request parameters do not match the method contract",
)
})?;
let received = self
.request_with_raw_result(cx, method, Some(params))
.await?;
if let Some(error) = received.response.error {
return Err(json_rpc_error_to_mcp(error));
}
let result = received.response.result.as_ref().ok_or_else(|| {
McpError::invalid_request("WebSocket successful response has no result")
})?;
let source = received.raw_result.as_deref().ok_or_else(|| {
McpError::invalid_request("Modern WebSocket response has no admitted result source")
})?;
decode_core_result_from_source(&request, result, Some(source))
}
#[cfg(feature = "tasks")]
fn require_final_tasks_direction(
&self,
name: &str,
direction: ExtensionDirection,
) -> McpResult<()> {
self.require_modern(name)?;
let discovery = self.session.server_discovery().ok_or_else(|| {
McpError::invalid_params(
"Modern Tasks requires the retained final server/discover response",
)
})?;
admit_final_tasks_discovery_surface(discovery, name, direction)
}
#[cfg(feature = "tasks")]
fn require_final_tasks_method(&self, method: &str) -> McpResult<()> {
self.require_final_tasks_direction(method, ExtensionDirection::ClientToServer)
}
#[cfg(feature = "tasks")]
fn require_final_tasks_result(&self, discriminator: &str) -> McpResult<()> {
self.require_modern("Tasks result")?;
let discovery = self.session.server_discovery().ok_or_else(|| {
McpError::invalid_params(
"Modern Tasks requires the retained final server/discover response",
)
})?;
admit_final_tasks_result_discriminator(discovery, discriminator)
}
#[cfg(feature = "tasks")]
fn final_task_request_meta(&self) -> McpResult<TaskRequestMeta> {
let params = self.with_final_tasks_client_capability(serde_json::json!({}))?;
let meta = params
.get("_meta")
.cloned()
.ok_or_else(|| McpError::internal_error("Modern Tasks request metadata was omitted"))?;
serde_json::from_value(meta)
.map(|meta| TaskRequestMeta { meta })
.map_err(|_| {
McpError::internal_error(
"Modern Tasks request metadata did not retain its final shape",
)
})
}
#[cfg(feature = "tasks")]
fn with_final_tasks_client_capability(
&self,
params: serde_json::Value,
) -> McpResult<serde_json::Value> {
let mut params = self.with_modern_request_metadata(params)?;
let extensions = params
.get_mut("_meta")
.and_then(serde_json::Value::as_object_mut)
.and_then(|metadata| metadata.get_mut(FINAL_CLIENT_CAPABILITIES_META_KEY))
.and_then(serde_json::Value::as_object_mut)
.and_then(|capabilities| {
capabilities
.entry("extensions")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
.as_object_mut()
})
.ok_or_else(|| {
McpError::internal_error("Modern Tasks client extensions must be an object")
})?;
extensions.insert(
fastmcp_protocol::TASKS_EXTENSION.to_owned(),
serde_json::json!({}),
);
Ok(params)
}
#[cfg(feature = "tasks")]
async fn request_final_tasks<P, R>(
&mut self,
cx: &Cx,
method: &str,
parameters: P,
) -> McpResult<R>
where
P: serde::Serialize,
R: serde::de::DeserializeOwned,
IO: Send + 'static,
{
if cx.checkpoint().is_err() {
return Err(self.terminal_cancellation(cx).await);
}
let params = serde_json::to_value(parameters).map_err(|_| {
McpError::invalid_params("Modern Tasks request parameters could not serialize")
})?;
let request_id = self.allocate_request_id()?;
let received = self
.request_admitted_with_raw_result(
cx,
JsonRpcRequest::new(method, Some(params), request_id),
None,
)
.await?;
if let Some(error) = received.response.error {
return Err(json_rpc_error_to_mcp(error));
}
let source = received.raw_result.ok_or_else(|| {
McpError::invalid_request("Modern Tasks response has no admitted result source")
})?;
serde_json::from_str(&source).map_err(|_| {
McpError::invalid_request("Modern Tasks response has an invalid result shape")
})
}
fn try_dispatch_modern_reverse_request(
&self,
request: &JsonRpcRequest,
) -> McpResult<Option<LiveServerRequestDispatch>>
where
IO: Send + 'static,
{
if !self.reverse_request_handlers.has_modern_handlers() {
return Ok(None);
}
let Some(id) = request.id.clone() else {
return Ok(None);
};
match request.method.as_str() {
"sampling/createMessage" => {
let Some(handler) = self
.reverse_request_handlers
.modern_sampling_create_message
.as_ref()
else {
return Ok(None);
};
let params =
match decode_reverse_request_params::<FinalCreateMessageParams>(request) {
Ok(params) => params,
Err(error) => {
return Ok(Some(LiveServerRequestDispatch::Immediate(
reverse_request_response::<FinalCreateMessageResult>(
id,
Err(error),
),
)));
}
};
match self
.reverse_callback_pool
.dispatch(id.clone(), params, Arc::clone(handler))
{
Ok(()) => Ok(Some(LiveServerRequestDispatch::CallbackAdmitted)),
Err(error) if self.reverse_callback_pool.terminal_error().is_some() => {
Err(error)
}
Err(error) => Ok(Some(LiveServerRequestDispatch::Immediate(
reverse_request_response::<FinalCreateMessageResult>(id, Err(error)),
))),
}
}
"elicitation/create" => {
let Some(handler) = self
.reverse_request_handlers
.modern_elicitation_create
.as_ref()
else {
return Ok(None);
};
let params = match decode_reverse_request_params::<ElicitRequestParams>(request) {
Ok(params) => params,
Err(error) => {
return Ok(Some(LiveServerRequestDispatch::Immediate(
reverse_request_response::<ElicitResult>(id, Err(error)),
)));
}
};
match self
.reverse_callback_pool
.dispatch(id.clone(), params, Arc::clone(handler))
{
Ok(()) => Ok(Some(LiveServerRequestDispatch::CallbackAdmitted)),
Err(error) if self.reverse_callback_pool.terminal_error().is_some() => {
Err(error)
}
Err(error) => Ok(Some(LiveServerRequestDispatch::Immediate(
reverse_request_response::<ElicitResult>(id, Err(error)),
))),
}
}
"roots/list" => {
let Some(handler) = self.reverse_request_handlers.modern_roots_list.as_ref() else {
return Ok(None);
};
let params =
match decode_reverse_request_params::<FinalEmbeddedRootsListParams>(request) {
Ok(params) => params,
Err(error) => {
return Ok(Some(LiveServerRequestDispatch::Immediate(
reverse_request_response::<FinalEmbeddedRootsListResult>(
id,
Err(error),
),
)));
}
};
match self
.reverse_callback_pool
.dispatch(id.clone(), params, Arc::clone(handler))
{
Ok(()) => Ok(Some(LiveServerRequestDispatch::CallbackAdmitted)),
Err(error) if self.reverse_callback_pool.terminal_error().is_some() => {
Err(error)
}
Err(error) => Ok(Some(LiveServerRequestDispatch::Immediate(
reverse_request_response::<FinalEmbeddedRootsListResult>(id, Err(error)),
))),
}
}
_ => Ok(None),
}
}
fn dispatch_legacy_reverse_request(
&self,
request: &JsonRpcRequest,
) -> McpResult<LiveServerRequestDispatch>
where
IO: Send + 'static,
{
if let Some(error) = self.reverse_callback_pool.terminal_error() {
return Err(error);
}
let Some(id) = request.id.clone() else {
return Err(McpError::invalid_request(
"WebSocket exact-2024 reverse request requires a JSON-RPC ID",
));
};
if request.method.starts_with("notifications/") {
return Ok(LiveServerRequestDispatch::Immediate(
invalid_notification_request_response(request).unwrap_or_else(|| {
JsonRpcMessage::Response(JsonRpcResponse::error(
Some(id),
McpError::invalid_request("invalid reverse notification request").into(),
))
}),
));
}
if request.method == "ping" {
return Ok(LiveServerRequestDispatch::Immediate(
JsonRpcMessage::Response(JsonRpcResponse::success(id, serde_json::json!({}))),
));
}
let dispatch =
match request.method.as_str() {
"sampling/createMessage" => match self
.reverse_request_handlers
.sampling_create_message
.as_ref()
{
Some(handler) => {
let params = decode_reverse_request_params::<CreateMessageParams>(request);
match params {
Ok(params) => match self.reverse_callback_pool.dispatch(
id.clone(),
params,
Arc::clone(handler),
) {
Ok(()) => LiveServerRequestDispatch::CallbackAdmitted,
Err(error)
if self.reverse_callback_pool.terminal_error().is_some() =>
{
return Err(error);
}
Err(error) => LiveServerRequestDispatch::Immediate(
reverse_request_response::<CreateMessageResult>(id, Err(error)),
),
},
Err(error) => {
LiveServerRequestDispatch::Immediate(reverse_request_response::<
CreateMessageResult,
>(
id, Err(error)
))
}
}
}
None => LiveServerRequestDispatch::Immediate(reverse_request_response::<
CreateMessageResult,
>(
id,
Err(McpError::method_not_found("sampling/createMessage")),
)),
},
"roots/list" => match self.reverse_request_handlers.roots_list.as_ref() {
Some(handler) => {
let params = decode_reverse_request_params::<ListRootsParams>(request);
match params {
Ok(params) => match self.reverse_callback_pool.dispatch(
id.clone(),
params,
Arc::clone(handler),
) {
Ok(()) => LiveServerRequestDispatch::CallbackAdmitted,
Err(error)
if self.reverse_callback_pool.terminal_error().is_some() =>
{
return Err(error);
}
Err(error) => LiveServerRequestDispatch::Immediate(
reverse_request_response::<ListRootsResult>(id, Err(error)),
),
},
Err(error) => {
LiveServerRequestDispatch::Immediate(reverse_request_response::<
ListRootsResult,
>(
id, Err(error)
))
}
}
}
None => LiveServerRequestDispatch::Immediate(reverse_request_response::<
ListRootsResult,
>(
id,
Err(McpError::method_not_found("roots/list")),
)),
},
_ => LiveServerRequestDispatch::Immediate(
method_not_found_response(request).unwrap_or_else(|| {
JsonRpcMessage::Response(JsonRpcResponse::error(
Some(id),
McpError::method_not_found(&request.method).into(),
))
}),
),
};
Ok(dispatch)
}
fn cancel_legacy_reverse_callback(&self, request: &JsonRpcRequest) -> bool {
let Ok(CancellationWireMessage::Legacy2024 { params, .. }) =
CancellationWireMessage::decode(
ProtocolEra::Legacy2024,
CancellationSender::Server,
request,
)
else {
return false;
};
self.reverse_callback_pool.cancel(¶ms.request_id)
}
fn drain_completed_reverse_callbacks(&self) -> McpResult<()> {
if let Some(error) = self.reverse_callback_pool.terminal_error() {
return Err(error);
}
self.reverse_callback_pool.reap_finished_tasks()?;
self.reverse_callback_pool
.terminal_error()
.map_or(Ok(()), Err)
}
/// Races the one owned ingress half against a callback-terminal signal.
///
/// The frame branch is the only branch that owns `receiver`; it returns
/// that half to this client on every non-terminal receive outcome. If a
/// retained callback publishes a terminal failure first, the receive future
/// is cancel-safely dropped without creating another WebSocket reader.
///
/// When a caller-owned cancellation domain is present, ingress is polled
/// so a cancelled request can retire without waiting for a peer frame.
/// Exact-2024 peers may suppress that request's terminal JSON-RPC result.
async fn recv_with_callback_terminal(
&mut self,
cx: &Cx,
cancellation: Option<&McpRequestCancellation>,
) -> Result<ReceivedTransportFrame, TransportError>
where
IO: Send + 'static,
{
if self.reverse_callback_pool.terminal_error().is_some() {
return Err(TransportError::Closed);
}
if cancellation.is_some() {
return self
.recv_with_callback_terminal_interruptible(cx, cancellation)
.await;
}
let mut receiver = self.receiver.take().ok_or(TransportError::Closed)?;
let mut terminal = self.reverse_callback_pool.terminal.subscribe(cx);
let receive_cx = cx.clone();
let terminal_cx = cx.clone();
let raced = cx
.race(vec![
Box::pin(async move {
let result = receiver.recv_with_source(&receive_cx).await;
WebSocketReceiveRace::Frame {
receiver,
result: Box::new(result),
}
}),
Box::pin(async move {
let _ = terminal.recv(&terminal_cx).await;
WebSocketReceiveRace::CallbackTerminal
}),
])
.await;
match raced {
Ok(WebSocketReceiveRace::Frame { receiver, result }) => {
self.receiver = Some(receiver);
*result
}
Ok(WebSocketReceiveRace::CallbackTerminal) => {
if cx.checkpoint().is_err() {
Err(TransportError::Cancelled)
} else {
Err(TransportError::Closed)
}
}
Err(_) if cx.checkpoint().is_err() => Err(TransportError::Cancelled),
Err(_) => Err(TransportError::Closed),
}
}
async fn recv_with_callback_terminal_interruptible(
&mut self,
cx: &Cx,
cancellation: Option<&McpRequestCancellation>,
) -> Result<ReceivedTransportFrame, TransportError>
where
IO: Send + 'static,
{
let mut receiver = self.receiver.take().ok_or(TransportError::Closed)?;
let outcome = loop {
if cancellation.is_some_and(McpRequestCancellation::is_cancel_requested)
|| cx.checkpoint().is_err()
{
break Err(TransportError::Cancelled);
}
if self.reverse_callback_pool.terminal_error().is_some() {
break Err(if cx.checkpoint().is_err() {
TransportError::Cancelled
} else {
TransportError::Closed
});
}
match asupersync::time::timeout(
cx.now(),
Duration::from_nanos(WEBSOCKET_CANCELLED_RECV_POLL_NANOS),
receiver.recv_with_source(cx),
)
.await
{
Ok(result) => break result,
Err(_) => continue,
}
};
self.receiver = Some(receiver);
outcome
}
async fn send_message(&self, cx: &Cx, message: &JsonRpcMessage) -> Result<(), TransportError> {
if self.reverse_callback_pool.terminal_error().is_some() {
return Err(TransportError::Closed);
}
send_websocket_callback_message(&self.sender, cx, message).await
}
fn admit_raw_client_request(
&self,
method: &str,
params: Option<&serde_json::Value>,
) -> McpResult<()> {
let era = self.selected_protocol_era();
if matches!(
method,
SERVER_DISCOVER_METHOD | "initialize" | "notifications/initialized"
) {
return Err(McpError::invalid_params(
"WebSocket raw requests cannot replay negotiated control methods",
));
}
if era == ProtocolEra::Modern2026 {
let method_definition = final_2026_07_28_method(method).ok_or_else(|| {
McpError::invalid_params(
"WebSocket modern raw request method is not admitted by the final client union",
)
})?;
if !method_definition
.direction
.admits_sender(Final2026Peer::Client)
|| method_definition.envelope != Final2026EnvelopeKind::Request
{
return Err(McpError::invalid_params(
"WebSocket modern raw request method has the wrong direction or envelope",
));
}
}
#[cfg(feature = "legacy-2024-11-05")]
let request = CoreRequest::decode(era, method, params).map_err(|_| {
McpError::invalid_params(
"WebSocket raw request is not admitted by the negotiated protocol era",
)
})?;
#[cfg(not(feature = "legacy-2024-11-05"))]
CoreRequest::decode(era, method, params).map_err(|_| {
McpError::invalid_params(
"WebSocket raw request is not admitted by the negotiated protocol era",
)
})?;
#[cfg(feature = "legacy-2024-11-05")]
if matches!(
request,
CoreRequest::Legacy(LegacyCoreRequest::SamplingCreateMessage(_))
) {
return Err(McpError::invalid_params(
"WebSocket raw request method is server-to-client in MCP 2024-11-05",
));
}
Ok(())
}
fn allocate_request_id(&mut self) -> McpResult<RequestId> {
let id = self.next_id;
self.next_id = self.next_id.checked_add(1).ok_or_else(|| {
McpError::internal_error("WebSocket client request ID space exhausted")
})?;
let id = i64::try_from(id)
.map_err(|_| McpError::internal_error("WebSocket client request ID exceeds i64"))?;
Ok(RequestId::Number(id))
}
async fn terminal_transport_error(&mut self, cx: &Cx, error: TransportError) -> McpError {
self.closed = true;
let _ = self.settle_close(cx).await;
transport_error_to_mcp(error)
}
async fn terminal_cancellation(&mut self, cx: &Cx) -> McpError {
self.closed = true;
let _ = self.settle_close(cx).await;
McpError::request_cancelled()
}
async fn terminal_callback_error(&mut self, cx: &Cx, error: McpError) -> McpError {
self.closed = true;
let _ = self.settle_close(cx).await;
error
}
async fn close_after_protocol_error(&mut self, cx: &Cx, message: &str) -> McpError {
self.closed = true;
let _ = self.settle_close(cx).await;
McpError::invalid_request(message)
}
async fn settle_close(&mut self, cx: &Cx) -> McpResult<()> {
if self.close_settled {
return Ok(());
}
self.closed = true;
// A panicked callback is already terminal, but its retained task has
// been observed and removed by `join_bounded`. Continue closing both
// transport halves before surfacing that callback failure so every
// terminal path has the same structural close election.
let callbacks_result = self.reverse_callback_pool.join_bounded(cx).await;
let sender_result = self
.send_message_close(cx)
.await
.map_err(transport_error_to_mcp);
let receiver_result = match self.receiver.as_mut() {
Some(receiver) => receiver.close(cx).await.map_err(transport_error_to_mcp),
None => Ok(()),
};
callbacks_result?;
match (sender_result, receiver_result) {
(Ok(()), Ok(())) => {
self.close_settled = true;
Ok(())
}
(Err(error), _) | (_, Err(error)) => Err(error),
}
}
async fn send_message_close(&self, cx: &Cx) -> Result<(), TransportError> {
let mut sender = OwnedMutexGuard::lock(Arc::clone(&self.sender), cx)
.await
.map_err(|error| match error {
asupersync::sync::LockError::Cancelled => TransportError::Cancelled,
asupersync::sync::LockError::TimedOut(_) => TransportError::Timeout,
asupersync::sync::LockError::Poisoned
| asupersync::sync::LockError::PolledAfterCompletion => TransportError::Closed,
})?;
sender.close(cx).await
}
}
#[cfg(feature = "websocket-experimental")]
async fn async_websocket_discover<IO>(
receiver: &mut AsyncWsClientRecvHalf<IO>,
sender: &mut AsyncWsClientSendHalf<IO>,
cx: &Cx,
client_info: &ClientInfo,
client_capabilities: &ClientCapabilities,
mcp_apps_settings: Option<&McpAppsClientSettings>,
client_extension_runtime: Option<&ClientExtensionRuntime>,
id: i64,
) -> Result<WebSocketInitialization, WebSocketHandshakeError>
where
IO: AsyncRead + AsyncWrite + Unpin,
{
#[cfg(not(feature = "apps"))]
let _ = mcp_apps_settings;
let mut params = serde_json::to_value(ServerDiscoverRequest::default()).map_err(|_| {
WebSocketHandshakeError::Mcp(McpError::internal_error(
"Modern WebSocket discovery parameters could not be serialized",
))
})?;
let metadata = params
.get_mut("_meta")
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
WebSocketHandshakeError::Mcp(McpError::internal_error(
"Modern WebSocket discovery metadata is missing",
))
})?;
metadata.insert(
FINAL_CLIENT_CAPABILITIES_META_KEY.to_owned(),
serde_json::to_value(client_capabilities).map_err(|_| {
WebSocketHandshakeError::Mcp(McpError::internal_error(
"Modern WebSocket client capabilities could not be serialized",
))
})?,
);
metadata.insert(
"io.modelcontextprotocol/clientInfo".to_owned(),
serde_json::to_value(client_info).map_err(|_| {
WebSocketHandshakeError::Mcp(McpError::internal_error(
"Modern WebSocket client identity could not be serialized",
))
})?,
);
if let Some(configured_extensions) =
client_extension_runtime.map(ClientExtensionRuntime::client_wire_extensions)
{
let capabilities = metadata
.get_mut(FINAL_CLIENT_CAPABILITIES_META_KEY)
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
WebSocketHandshakeError::Mcp(McpError::internal_error(
"Modern WebSocket discovery omitted client capabilities",
))
})?;
let extensions = capabilities
.entry("extensions")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
.as_object_mut()
.ok_or_else(|| {
WebSocketHandshakeError::Mcp(McpError::internal_error(
"Modern WebSocket discovery extensions are not an object",
))
})?;
extensions.extend(configured_extensions);
}
#[cfg(feature = "apps")]
if client_extension_runtime.is_none_or(|runtime| !runtime.configures_mcp_apps())
&& let Some(settings) = mcp_apps_settings
{
let capabilities = metadata
.get_mut(FINAL_CLIENT_CAPABILITIES_META_KEY)
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
WebSocketHandshakeError::Mcp(McpError::internal_error(
"Modern WebSocket discovery omitted client capabilities",
))
})?;
let extensions = capabilities
.entry("extensions")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
.as_object_mut()
.ok_or_else(|| {
WebSocketHandshakeError::Mcp(McpError::internal_error(
"Modern WebSocket discovery extensions are not an object",
))
})?;
extensions.insert(
fastmcp_protocol::extensions::OFFICIAL_MCP_APPS_EXTENSION_ID.to_owned(),
settings.to_extension_settings().into_value(),
);
}
let response = async_websocket_exchange(
receiver,
sender,
cx,
JsonRpcRequest::new(SERVER_DISCOVER_METHOD, Some(params), id),
)
.await?;
let source = response.raw_result.ok_or_else(|| {
WebSocketHandshakeError::Mcp(McpError::invalid_request(
"Modern WebSocket discovery response has no admitted result source",
))
})?;
let discovery = serde_json::from_str::<ServerDiscoverResult>(&source).map_err(|_| {
WebSocketHandshakeError::Mcp(McpError::invalid_request(
"Modern WebSocket discovery response has an invalid result",
))
})?;
if !discovery
.supported_versions()
.iter()
.any(|version| version == MODERN_PROTOCOL_VERSION)
{
return Err(WebSocketHandshakeError::Mcp(McpError::internal_error(
UNSUPPORTED_PROTOCOL_VERSION_ERROR,
)));
}
let server_info = discovery.server_info().cloned().ok_or_else(|| {
WebSocketHandshakeError::Mcp(McpError::invalid_request(
"Modern WebSocket discovery response has no server identity",
))
})?;
Ok(WebSocketInitialization::Modern {
server_info,
discovery,
})
}
#[cfg(feature = "websocket-experimental")]
async fn async_websocket_initialize<IO>(
receiver: &mut AsyncWsClientRecvHalf<IO>,
sender: &mut AsyncWsClientSendHalf<IO>,
cx: &Cx,
client_info: &ClientInfo,
client_capabilities: &ClientCapabilities,
id: i64,
) -> Result<InitializeResult, WebSocketHandshakeError>
where
IO: AsyncRead + AsyncWrite + Unpin,
{
let response = async_websocket_exchange(
receiver,
sender,
cx,
JsonRpcRequest::new(
"initialize",
Some(
serde_json::to_value(InitializeParams {
protocol_version: PROTOCOL_VERSION.to_owned(),
capabilities: client_capabilities.clone(),
client_info: client_info.clone(),
})
.map_err(|_| {
WebSocketHandshakeError::Mcp(McpError::internal_error(
"WebSocket initialize parameters could not be serialized",
))
})?,
),
id,
),
)
.await?;
let result = response.result.ok_or_else(|| {
WebSocketHandshakeError::Mcp(McpError::invalid_request(
"WebSocket initialize response has no result",
))
})?;
let result = serde_json::from_value::<InitializeResult>(result).map_err(|_| {
WebSocketHandshakeError::Mcp(McpError::invalid_request(
"WebSocket initialize response has an invalid result",
))
})?;
validate_initialize_result(&result).map_err(WebSocketHandshakeError::Mcp)?;
Ok(result)
}
#[cfg(feature = "websocket-experimental")]
async fn async_websocket_exchange<IO>(
receiver: &mut AsyncWsClientRecvHalf<IO>,
sender: &mut AsyncWsClientSendHalf<IO>,
cx: &Cx,
request: JsonRpcRequest,
) -> Result<WebSocketHandshakeResponse, WebSocketHandshakeError>
where
IO: AsyncRead + AsyncWrite + Unpin,
{
let request_id = request.id.clone().ok_or_else(|| {
WebSocketHandshakeError::Mcp(McpError::internal_error(
"WebSocket handshake request requires an ID",
))
})?;
if let Err(error) = sender.send(cx, &JsonRpcMessage::Request(request)).await {
return Err(WebSocketHandshakeError::Mcp(transport_error_to_mcp(error)));
}
let frame = match receiver.recv_with_source(cx).await {
Ok(frame) => frame,
Err(error) => return Err(WebSocketHandshakeError::Mcp(transport_error_to_mcp(error))),
};
let JsonRpcMessage::Response(response) = frame.message() else {
return Err(WebSocketHandshakeError::Mcp(McpError::invalid_request(
"WebSocket handshake received a non-response frame",
)));
};
if !response
.id
.as_ref()
.is_some_and(|response_id| response_id.correlates_with(&request_id))
{
return Err(WebSocketHandshakeError::Mcp(McpError::invalid_request(
"WebSocket handshake response ID does not match its request",
)));
}
if let Some(error) = response.error.clone() {
if error.code.as_i32() == Some(-32601) {
return Err(WebSocketHandshakeError::MethodNotFound);
}
return Err(WebSocketHandshakeError::Mcp(json_rpc_error_to_mcp(error)));
}
let response = response.clone();
let raw_result = raw_result_from_admitted_response(&response, frame, "WebSocket")
.map_err(WebSocketHandshakeError::Mcp)?;
Ok(WebSocketHandshakeResponse {
result: response.result,
raw_result,
})
}
#[cfg(feature = "websocket-experimental")]
async fn close_async_websocket_halves<IO>(
receiver: &mut AsyncWsClientRecvHalf<IO>,
sender: &mut AsyncWsClientSendHalf<IO>,
cx: &Cx,
) -> Result<(), TransportError>
where
IO: AsyncRead + AsyncWrite + Unpin,
{
let sender_result = sender.close(cx).await;
let receiver_result = receiver.close(cx).await;
sender_result.and(receiver_result)
}
/// Completes session admission while this connection still owns both halves.
///
/// The handshake driver is the single teardown owner: wire exchange helpers
/// only report semantic failures, and this boundary closes each split half
/// once before preserving an admission failure for the caller.
#[cfg(feature = "websocket-experimental")]
async fn admit_websocket_session_or_close<IO>(
receiver: &mut AsyncWsClientRecvHalf<IO>,
sender: &mut AsyncWsClientSendHalf<IO>,
cx: &Cx,
session: McpResult<ClientSession>,
) -> McpResult<ClientSession>
where
IO: AsyncRead + AsyncWrite + Unpin,
{
match session {
Ok(session) => Ok(session),
Err(error) => {
let _ = close_async_websocket_halves(receiver, sender, cx).await;
Err(error)
}
}
}
fn validate_timeout_duration(
timeout: Duration,
maximum: Duration,
error: &'static str,
) -> McpResult<()> {
if timeout < Duration::from_millis(1) || timeout > maximum {
return Err(McpError::invalid_params(error));
}
Ok(())
}
/// Transport-neutral I/O for a connection whose protocol era is already fixed.
///
/// This type intentionally accepts only independently owned halves. It has no
/// protocol policy, reconnect factory, subprocess handle, or lifecycle state,
/// so it cannot probe or downgrade an `Auto` connection. The existing stdio
/// client retains ownership of child lifecycle and reverse-handler control
/// writes until those concerns receive their own transport-neutral slice.
#[derive(Clone)]
struct NegotiatedClientIo<R, S> {
receiver: R,
sender: S,
}
impl<R, S> NegotiatedClientIo<R, S>
where
R: ClientTransportRecvHalf,
S: TransportSendHalf,
{
fn new(receiver: R, sender: S) -> Self {
Self { receiver, sender }
}
fn recv(&mut self, cx: &Cx) -> Result<ReceivedTransportFrame, TransportError> {
self.receiver.recv_with_source(cx)
}
fn send(&mut self, cx: &Cx, message: &JsonRpcMessage) -> Result<(), TransportError> {
self.sender.send(cx, message)
}
fn into_parts(self) -> (R, S) {
(self.receiver, self.sender)
}
}
/// Shared adapter over the client-owned stdio ingress half.
///
/// The adapter owns only the receiver lock. Its matching writer adapter owns a
/// distinct lock, so a blocked receive never holds child stdin away from a
/// reverse-callback worker.
#[derive(Clone)]
struct SharedStdioRecv(Arc<Mutex<StdioRecvHalf<ChildStdout>>>);
impl TransportRecvHalf for SharedStdioRecv {
fn recv(&mut self, cx: &Cx) -> Result<JsonRpcMessage, TransportError> {
self.0.lock().map_err(|_| TransportError::Closed)?.recv(cx)
}
fn close(&mut self) -> Result<(), TransportError> {
self.0.lock().map_err(|_| TransportError::Closed)?.close()
}
}
impl ClientTransportRecvHalf for SharedStdioRecv {
fn recv_with_source(&mut self, cx: &Cx) -> Result<ReceivedTransportFrame, TransportError> {
let mut receiver = self.0.lock().map_err(|_| TransportError::Closed)?;
receiver.recv(cx)?;
admitted_stdio_frame(&receiver)
}
}
impl SharedStdioRecv {
#[cfg(unix)]
fn recv_until_with_source(
&mut self,
cx: &Cx,
deadline: Option<Instant>,
) -> Result<(ReceivedTransportFrame, Instant), TransportError> {
let mut receiver = self.0.lock().map_err(|_| TransportError::Closed)?;
if let Err(error) = receiver.recv_until_or_closed(cx, deadline) {
// A partial-frame deadline closes the split receiver before its
// wrapper returns. The wrapper consequently reports `Closed`
// even though the request-local deadline was the event that won.
// Preserve that elected outcome for the client while an EOF that
// arrives strictly before the deadline remains ordinary closure.
if matches!(error, TransportError::Closed)
&& deadline.is_some_and(|deadline| Instant::now() >= deadline)
{
return Err(TransportError::ReceiveDeadlineExceeded);
}
return Err(error);
}
let received_at = Instant::now();
let frame = admitted_stdio_frame(&receiver)?;
Ok((frame, received_at))
}
#[cfg(not(unix))]
fn recv_until_with_source(
&mut self,
cx: &Cx,
deadline: Option<Instant>,
) -> Result<(ReceivedTransportFrame, Instant), TransportError> {
// std::process::ChildStdout exposes no portable safe readiness
// primitive. Keep the existing frame-boundary deadline behavior.
if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
return Err(TransportError::ReceiveDeadlineExceeded);
}
self.recv_with_source(cx)
.map(|frame| (frame, Instant::now()))
}
}
/// Shared adapter over the client-owned stdio egress half.
///
/// It intentionally shares no lock with [`SharedStdioRecv`]. The selected
/// negotiated I/O therefore preserves the existing callback writer's ability
/// to respond while the sole reader is waiting for another inbound frame.
#[derive(Clone)]
struct SharedStdioSend(Arc<Mutex<StdioSendHalf<ChildStdin>>>);
impl TransportSendHalf for SharedStdioSend {
fn send(&mut self, cx: &Cx, message: &JsonRpcMessage) -> Result<(), TransportError> {
self.0
.lock()
.map_err(|_| TransportError::Closed)?
.send(cx, message)
}
fn close(&mut self) -> Result<(), TransportError> {
self.0.lock().map_err(|_| TransportError::Closed)?.close()
}
}
impl NegotiatedClientIo<SharedStdioRecv, SharedStdioSend> {
fn recv_until_with_source(
&mut self,
cx: &Cx,
deadline: Option<Instant>,
) -> Result<(ReceivedTransportFrame, Instant), TransportError> {
self.receiver.recv_until_with_source(cx, deadline)
}
}
/// The selected stdio transport view held by request executors.
///
/// Its receive half remains shared with [`Client`], but request executors are
/// always driven through admitted frames from the client. The `Transport`
/// implementation exists only to give the executor its request and
/// cancellation writer; its self-reader entry point is deliberately rejected
/// after the Client claims external ingress with `drive_frame`.
type SelectedStdioTransport = NegotiatedClientIo<SharedStdioRecv, SharedStdioSend>;
impl Transport for SelectedStdioTransport {
fn send(&mut self, cx: &Cx, message: &JsonRpcMessage) -> Result<(), TransportError> {
self.sender.send(cx, message)
}
fn recv(&mut self, cx: &Cx) -> Result<JsonRpcMessage, TransportError> {
self.receiver.recv(cx)
}
fn close(&mut self) -> Result<(), TransportError> {
self.receiver.close()?;
self.sender.close()
}
}
/// Extracts a response's exact raw result source from one admitted ingress frame.
///
/// The typed source-side response and raw source both originate from the same
/// bounded [`ReceivedTransportFrame`], so no caller can attach a reordered or
/// otherwise mismatched sidecar to a separately decoded response. The supplied
/// response is retained only to detect a stale stdio reuse-buffer race.
fn raw_result_from_admitted_response(
response: &JsonRpcResponse,
frame: ReceivedTransportFrame,
transport_name: &str,
) -> McpResult<Option<String>> {
let (message, source) = frame.into_parts();
let JsonRpcMessage::Response(source_response) = message else {
return Err(McpError::internal_error(format!(
"Admitted {transport_name} ingress frame was not a JSON-RPC response"
)));
};
if &source_response != response {
return Err(McpError::internal_error(format!(
"Typed {transport_name} response differs from its admitted source frame"
)));
}
let admission = decode_strict_jsonrpc_response(&source, source.len()).map_err(|_| {
McpError::internal_error(format!(
"Admitted {transport_name} response could not retain its raw result"
))
})?;
if admission.response() != response {
return Err(McpError::internal_error(format!(
"Typed {transport_name} response differs from its admitted source frame"
)));
}
Ok(admission.into_parts().1)
}
fn admitted_stdio_frame(
transport: &StdioRecvHalf<ChildStdout>,
) -> Result<ReceivedTransportFrame, TransportError> {
let source = transport
.last_received_frame()
.ok_or_else(|| {
TransportError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"stdio receive completed without an admitted source frame",
))
})?
.to_vec()
.into_boxed_slice();
ReceivedTransportFrame::admit(source)
}
#[cfg(unix)]
fn recv_child_transport(
transport: &mut StdioRecvHalf<ChildStdout>,
cx: &Cx,
deadline: Option<Instant>,
) -> Result<(ReceivedTransportFrame, Instant), TransportError> {
transport.recv_until_or_closed(cx, deadline)?;
let received_at = Instant::now();
let frame = admitted_stdio_frame(transport)?;
Ok((frame, received_at))
}
fn recv_shared_child_transport(
transport: &Arc<Mutex<StdioRecvHalf<ChildStdout>>>,
cx: &Cx,
deadline: Option<Instant>,
) -> Result<(ReceivedTransportFrame, Instant), TransportError> {
let mut receiver = transport.lock().map_err(|_| TransportError::Closed)?;
recv_child_transport(&mut receiver, cx, deadline)
}
#[cfg(unix)]
fn recv_initializing_child_transport(
transport: &mut StdioTransport<ChildStdout, ChildStdin>,
cx: &Cx,
deadline: Option<Instant>,
) -> Result<(JsonRpcMessage, Instant), TransportError> {
transport.recv_until_with_completion(cx, deadline)
}
#[cfg(not(unix))]
fn recv_initializing_child_transport(
transport: &mut StdioTransport<ChildStdout, ChildStdin>,
cx: &Cx,
deadline: Option<Instant>,
) -> Result<(JsonRpcMessage, Instant), TransportError> {
if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
return Err(TransportError::ReceiveDeadlineExceeded);
}
transport.recv_with_completion(cx)
}
#[cfg(not(unix))]
fn recv_child_transport(
transport: &mut StdioRecvHalf<ChildStdout>,
cx: &Cx,
deadline: Option<Instant>,
) -> Result<(ReceivedTransportFrame, Instant), TransportError> {
// std::process::ChildStdout exposes no portable safe readiness primitive.
// Keep the limitation explicit: non-Unix cancellation/deadlines are
// observed at frame boundaries, but cannot interrupt a blocking pipe read.
if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
return Err(TransportError::ReceiveDeadlineExceeded);
}
transport.recv(cx)?;
let received_at = Instant::now();
let frame = admitted_stdio_frame(transport)?;
Ok((frame, received_at))
}
#[cfg(unix)]
fn send_child_server_response_during_receive(
transport: &Arc<Mutex<StdioSendHalf<ChildStdin>>>,
_cx: &Cx,
message: &JsonRpcMessage,
) -> McpResult<()> {
transport
.lock()
.map_err(|_| McpError::internal_error("Client stdio response writer failed"))?
.try_send_control_message(message)
.map_err(transport_error_to_mcp)
}
#[cfg(not(unix))]
fn send_child_server_response_during_receive(
transport: &Arc<Mutex<StdioSendHalf<ChildStdin>>>,
cx: &Cx,
message: &JsonRpcMessage,
) -> McpResult<()> {
// Standard child pipes expose no portable nonblocking write on this path.
// Preserve frame-boundary behavior explicitly; the caller abandons the
// connection if this send itself fails.
transport
.lock()
.map_err(|_| McpError::internal_error("Client stdio response writer failed"))?
.send(cx, message)
.map_err(transport_error_to_mcp)
}
fn stdio_cancellation_control_message(
peer_era: ProtocolEra,
request_id: &RequestId,
) -> McpResult<JsonRpcMessage> {
let cancellation = match peer_era {
ProtocolEra::Legacy2024 => CancellationWireMessage::Legacy2024 {
sender: CancellationSender::Client,
params: CancelledParams {
request_id: request_id.clone(),
reason: None,
},
},
ProtocolEra::Modern2026 => CancellationWireMessage::Modern2026 {
sender: CancellationSender::Client,
params: FinalCancelledNotificationParams {
request_id: request_id.clone(),
reason: None,
meta: None,
additional: BTreeMap::default(),
},
},
};
cancellation
.encode()
.map(JsonRpcMessage::Request)
.map_err(|error| {
McpError::invalid_params(format!("Invalid cancellation control parameters: {error}"))
})
}
#[cfg(unix)]
fn send_initializing_child_server_response(
transport: &mut StdioTransport<ChildStdout, ChildStdin>,
_cx: &Cx,
message: &JsonRpcMessage,
) -> McpResult<()> {
transport
.try_send_control_message(message)
.map_err(transport_error_to_mcp)
}
#[cfg(not(unix))]
fn send_initializing_child_server_response(
transport: &mut StdioTransport<ChildStdout, ChildStdin>,
cx: &Cx,
message: &JsonRpcMessage,
) -> McpResult<()> {
transport.send(cx, message).map_err(transport_error_to_mcp)
}
fn initialize_child_transport(
transport: &mut StdioTransport<ChildStdout, ChildStdin>,
cx: &Cx,
client_info: &ClientInfo,
capabilities: &ClientCapabilities,
timeout_policy: RequestTimeoutPolicy,
) -> McpResult<InitializeResult> {
timeout_policy.validate()?;
let params = InitializeParams {
protocol_version: PROTOCOL_VERSION.to_string(),
capabilities: capabilities.clone(),
client_info: client_info.clone(),
};
let params = serde_json::to_value(params).map_err(|error| {
McpError::internal_error(format!("Failed to serialize params: {error}"))
})?;
let request = JsonRpcRequest::new("initialize", Some(params), INITIALIZE_REQUEST_ID);
transport
.send(cx, &JsonRpcMessage::Request(request))
.map_err(transport_error_to_mcp)?;
// Both timers start at the observed successful commit boundary. The
// initialization exchange has no request-owned progress token, so its idle
// timer is never reset. Synchronous writes remain governed by the caller's
// `Cx` checkpoints before this commit.
let committed_at = Instant::now();
let deadlines = RequestDeadlines::start_at(timeout_policy, committed_at)?;
let response = loop {
let (message, received_at) = recv_initializing_child_transport(
transport,
cx,
Some(deadlines.next()),
)
.map_err(|error| match error {
TransportError::ReceiveDeadlineExceeded => request_timeout_error(deadlines.next_kind()),
other => transport_error_to_mcp(other),
})?;
if let Some(source) = deadlines.expired_at(received_at) {
return Err(request_timeout_error(source));
}
validate_inbound_typed_message(&message)?;
match message {
JsonRpcMessage::Response(response) => {
validate_initialize_response_id(&response)?;
break response;
}
JsonRpcMessage::Request(request) => {
if let Some(response) = server_request_response(&request) {
send_initializing_child_server_response(transport, cx, &response)?;
}
}
}
};
if let Some(error) = response.error {
return Err(json_rpc_error_to_mcp(error));
}
let result = response
.result
.ok_or_else(|| McpError::invalid_request("Initialize response has no result"))?;
let result: InitializeResult = serde_json::from_value(result)
.map_err(|_| McpError::invalid_request(INVALID_INITIALIZE_PAYLOAD_ERROR))?;
validate_initialize_result(&result)?;
transport
.send(
cx,
&JsonRpcMessage::Request(JsonRpcRequest::initialized_notification()),
)
.map_err(transport_error_to_mcp)?;
Ok(result)
}
/// Maximum number of uncorrelated-response warnings emitted per connection.
///
/// Unknown and late IDs are peer activity, not authority to mutate a live
/// waiter. Bounding their diagnostics prevents a noisy peer from turning that
/// discard rule into an unbounded logging side effect.
const MAX_UNCORRELATED_RESPONSE_DIAGNOSTICS: u8 = 8;
/// Default per-connection in-flight waiter bound from LIMIT-01.
const MAX_IN_FLIGHT_RESPONSES: usize = 1_024;
/// Default combined waiter and late-response tombstone bound from LIMIT-01.
const MAX_RESPONSE_CORRELATIONS: usize = 4_096;
/// Default late-response tombstone retention from LIMIT-01.
const RESPONSE_TOMBSTONE_RETENTION: Duration = Duration::from_mins(10);
/// Maximum retained at-most-once cancellation-control markers per connection.
const MAX_CANCELLATION_CONTROL_IDS: usize = 4_096;
/// Retention for an emitted or attempted ordinary-request cancellation ID.
///
/// This is at least the maximum ordinary request lifetime, so a still-live
/// request generation cannot acquire a second control attempt after expiry.
/// A successfully admitted new waiter generation clears its ID explicitly.
const CANCELLATION_CONTROL_RETENTION: Duration = MAX_CLIENT_ABSOLUTE_TIMEOUT;
/// Maximum pages followed by one automatic pagination operation.
const MAX_AUTO_PAGINATION_PAGES: usize = 1_024;
/// Maximum aggregate items retained by one automatic pagination operation.
const MAX_AUTO_PAGINATION_ITEMS: usize = 100_000;
/// Maximum aggregate compact-JSON bytes retained by automatic pagination.
const MAX_AUTO_PAGINATION_SERIALIZED_BYTES: usize = 64 * 1_024 * 1_024;
/// Maximum UTF-8 bytes admitted in a peer-provided pagination cursor.
const MAX_PAGINATION_CURSOR_BYTES: usize = 4 * 1_024;
/// Compact JSON for an empty retained list is exactly `[]`.
const MIN_LIST_PAGE_SERIALIZED_BYTES: usize = 2;
/// Reserved counter value that permanently marks request-ID exhaustion.
///
/// The largest signed 64-bit value is not issued. Reserving it as a sentinel
/// lets the allocator fail closed without ever wrapping or reusing an ID.
const REQUEST_ID_EXHAUSTION_SENTINEL: u64 = 9_223_372_036_854_775_807;
const PAGINATION_PAGE_LIMIT_ERROR: &str = "Automatic pagination page limit exceeded";
const PAGINATION_ITEM_LIMIT_ERROR: &str = "Automatic pagination item limit exceeded";
const PAGINATION_BYTE_LIMIT_ERROR: &str = "Automatic pagination serialized-byte limit exceeded";
const PAGINATION_CURSOR_LIMIT_ERROR: &str = "Automatic pagination cursor byte limit exceeded";
const PAGINATION_CURSOR_CYCLE_ERROR: &str = "Automatic pagination cursor repeated";
const PAGINATION_CURSOR_NO_PROGRESS_ERROR: &str = "Pagination response cursor did not advance";
const PAGINATION_MEASUREMENT_ERROR: &str =
"Automatic pagination response could not be measured safely";
const LIST_PAGE_BYTE_LIMIT_ERROR: &str = "List page serialized-byte limit must be at least 2 bytes";
const PROGRESS_CALLBACK_PANIC_ERROR: &str = "Client progress callback failed";
const CONTROL_FRAME_CAPACITY_ERROR: &str = "MCP stdio control frame exceeds atomic capacity";
const INVALID_RESPONSE_ENVELOPE_ERROR: &str = "Invalid JSON-RPC response";
const INVALID_RESPONSE_PAYLOAD_ERROR: &str = "Invalid MCP response payload";
const TRANSPORT_CODEC_ERROR: &str = "Invalid MCP transport frame";
const INVALID_INITIALIZE_PAYLOAD_ERROR: &str = "Invalid MCP initialize response payload";
const INITIALIZE_RESPONSE_ID_ERROR: &str = "Initialize response ID mismatch";
const UNSUPPORTED_PROTOCOL_VERSION_ERROR: &str =
"Server selected an unsupported MCP protocol version";
const REDACTED_CLIENT_CALLBACK_PANIC: &[u8] =
b"fastmcp client callback panicked; panic payload redacted\n";
static INSTALL_CLIENT_CALLBACK_PANIC_HOOK: Once = Once::new();
thread_local! {
static REDACT_CLIENT_CALLBACK_PANIC: Cell<bool> = const { Cell::new(false) };
}
struct ClientCallbackPanicRedactionGuard {
previous: bool,
}
impl ClientCallbackPanicRedactionGuard {
fn enter() -> Self {
let previous = REDACT_CLIENT_CALLBACK_PANIC.with(|redact| redact.replace(true));
Self { previous }
}
}
impl Drop for ClientCallbackPanicRedactionGuard {
fn drop(&mut self) {
REDACT_CLIENT_CALLBACK_PANIC.with(|redact| redact.set(self.previous));
}
}
fn install_client_callback_panic_hook() {
INSTALL_CLIENT_CALLBACK_PANIC_HOOK.call_once(|| {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
if REDACT_CLIENT_CALLBACK_PANIC
.try_with(Cell::get)
.unwrap_or(false)
{
use std::io::Write as _;
let _ = std::io::stderr().write_all(REDACTED_CLIENT_CALLBACK_PANIC);
} else {
previous(panic_info);
}
}));
});
}
fn catch_client_callback_unwind<R>(callback: impl FnOnce() -> R) -> Result<R, Box<dyn Any + Send>> {
install_client_callback_panic_hook();
let _redaction = ClientCallbackPanicRedactionGuard::enter();
std::panic::catch_unwind(std::panic::AssertUnwindSafe(callback))
}
#[derive(Debug, Clone, Copy)]
struct PaginationLimits {
pages: usize,
items: usize,
serialized_bytes: usize,
cursor_bytes: usize,
}
impl PaginationLimits {
const DEFAULT: Self = Self {
pages: MAX_AUTO_PAGINATION_PAGES,
items: MAX_AUTO_PAGINATION_ITEMS,
serialized_bytes: MAX_AUTO_PAGINATION_SERIALIZED_BYTES,
cursor_bytes: MAX_PAGINATION_CURSOR_BYTES,
};
}
/// Bounded state for one automatic pagination operation.
///
/// Only fixed-width cursor digests are retained. The peer's opaque cursor is
/// never copied into diagnostics or the cycle-detection set.
struct PaginationBudget {
limits: PaginationLimits,
pages: usize,
items: usize,
serialized_bytes: usize,
seen_cursors: std::collections::HashSet<Sha256Digest>,
}
/// Caller-selected bounds for acquiring one page of a list operation.
///
/// MCP's tool, resource, template, and prompt list requests do not carry a
/// client-side item limit. A peer can therefore return more data in one page
/// than a caller intends to retain. These limits bound the retained page and
/// make that loss visible through [`BoundedListPage::local_truncated`]. The
/// normal transport message-size limit remains the first line of defense while
/// the response is being received and decoded.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ListPageLimits {
/// Maximum number of list entries to retain.
pub max_items: usize,
/// Maximum compact-JSON bytes for the complete retained `Vec`, including
/// its brackets and commas. Values below two are invalid because even an
/// empty vector serializes as `[]`.
pub max_serialized_bytes: usize,
}
impl ListPageLimits {
/// Creates limits for a single list page.
#[must_use]
pub const fn new(max_items: usize, max_serialized_bytes: usize) -> Self {
Self {
max_items,
max_serialized_bytes,
}
}
fn validate(self) -> McpResult<()> {
if self.max_serialized_bytes < MIN_LIST_PAGE_SERIALIZED_BYTES {
return Err(McpError::invalid_params(LIST_PAGE_BYTE_LIMIT_ERROR));
}
Ok(())
}
}
/// A bounded, single-page list acquisition.
#[derive(Debug, Clone, PartialEq)]
pub struct BoundedListPage<T> {
/// Entries retained within the caller's item and byte budgets.
pub items: Vec<T>,
/// Opaque cursor supplied by the peer for the following page. This is
/// suppressed when [`Self::local_truncated`] is true because following the
/// peer cursor would skip entries omitted from the current peer page.
pub next_cursor: Option<String>,
/// Whether entries from the current peer page were omitted locally.
pub local_truncated: bool,
/// Whether the peer supplied a cursor indicating another peer page.
pub peer_has_more: bool,
}
impl PaginationBudget {
fn new() -> Self {
Self::with_limits(PaginationLimits::DEFAULT)
}
fn with_limits(limits: PaginationLimits) -> Self {
Self {
limits,
pages: 0,
items: 0,
serialized_bytes: 0,
seen_cursors: std::collections::HashSet::new(),
}
}
fn begin_page(&mut self) -> McpResult<()> {
let pages = self
.pages
.checked_add(1)
.ok_or_else(|| McpError::internal_error(PAGINATION_PAGE_LIMIT_ERROR))?;
if pages > self.limits.pages {
return Err(McpError::internal_error(PAGINATION_PAGE_LIMIT_ERROR));
}
self.pages = pages;
Ok(())
}
fn admit_next_cursor(&mut self, cursor: Option<String>) -> McpResult<Option<String>> {
let Some(cursor) = cursor else {
return Ok(None);
};
let digest = sha256_bounded(cursor.as_bytes(), self.limits.cursor_bytes)
.map_err(|_| McpError::internal_error(PAGINATION_CURSOR_LIMIT_ERROR))?;
if !self.seen_cursors.insert(digest) {
return Err(McpError::internal_error(PAGINATION_CURSOR_CYCLE_ERROR));
}
Ok(Some(cursor))
}
fn account_page<T: serde::Serialize>(&mut self, items: &[T]) -> McpResult<()> {
let item_count = self
.items
.checked_add(items.len())
.ok_or_else(|| McpError::internal_error(PAGINATION_ITEM_LIMIT_ERROR))?;
if item_count > self.limits.items {
return Err(McpError::internal_error(PAGINATION_ITEM_LIMIT_ERROR));
}
let remaining_bytes = self
.limits
.serialized_bytes
.checked_sub(self.serialized_bytes)
.ok_or_else(|| McpError::internal_error(PAGINATION_BYTE_LIMIT_ERROR))?;
let page_bytes = measure_serialized_bytes(items, remaining_bytes)?;
let serialized_bytes = self
.serialized_bytes
.checked_add(page_bytes)
.ok_or_else(|| McpError::internal_error(PAGINATION_BYTE_LIMIT_ERROR))?;
if serialized_bytes > self.limits.serialized_bytes {
return Err(McpError::internal_error(PAGINATION_BYTE_LIMIT_ERROR));
}
self.items = item_count;
self.serialized_bytes = serialized_bytes;
Ok(())
}
}
struct SerializedByteCounter {
bytes: usize,
limit: usize,
exceeded: bool,
}
impl std::io::Write for SerializedByteCounter {
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
let Some(bytes) = self.bytes.checked_add(buffer.len()) else {
self.exceeded = true;
return Err(std::io::Error::other(PAGINATION_BYTE_LIMIT_ERROR));
};
if bytes > self.limit {
self.exceeded = true;
return Err(std::io::Error::other(PAGINATION_BYTE_LIMIT_ERROR));
}
self.bytes = bytes;
Ok(buffer.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn measure_serialized_bytes<T: serde::Serialize + ?Sized>(
value: &T,
limit: usize,
) -> McpResult<usize> {
let mut counter = SerializedByteCounter {
bytes: 0,
limit,
exceeded: false,
};
match serde_json::to_writer(&mut counter, value) {
Ok(()) => Ok(counter.bytes),
Err(_error) if counter.exceeded => {
Err(McpError::internal_error(PAGINATION_BYTE_LIMIT_ERROR))
}
Err(_error) => Err(McpError::internal_error(PAGINATION_MEASUREMENT_ERROR)),
}
}
fn bounded_list_page<T: serde::Serialize>(
items: Vec<T>,
request_cursor: Option<&str>,
next_cursor: Option<String>,
limits: ListPageLimits,
) -> McpResult<BoundedListPage<T>> {
limits.validate()?;
let original_items = items.len();
let mut retained = Vec::with_capacity(original_items.min(limits.max_items));
let mut local_truncated = original_items > limits.max_items;
let mut serialized_bytes = MIN_LIST_PAGE_SERIALIZED_BYTES;
for item in items.into_iter().take(limits.max_items) {
let separator_bytes = usize::from(!retained.is_empty());
let Some(remaining) = limits
.max_serialized_bytes
.checked_sub(serialized_bytes.saturating_add(separator_bytes))
else {
local_truncated = true;
break;
};
let item_bytes = match measure_serialized_bytes(&item, remaining) {
Ok(item_bytes) => item_bytes,
Err(error) if error.message == PAGINATION_BYTE_LIMIT_ERROR => {
local_truncated = true;
break;
}
Err(error) => return Err(error),
};
let Some(next_serialized_bytes) = serialized_bytes
.checked_add(separator_bytes)
.and_then(|bytes| bytes.checked_add(item_bytes))
else {
local_truncated = true;
break;
};
if next_serialized_bytes > limits.max_serialized_bytes {
local_truncated = true;
break;
}
serialized_bytes = next_serialized_bytes;
retained.push(item);
}
let mut cursor_budget = PaginationBudget::with_limits(PaginationLimits {
pages: 1,
items: limits.max_items,
serialized_bytes: limits.max_serialized_bytes,
cursor_bytes: MAX_PAGINATION_CURSOR_BYTES,
});
let validated_next_cursor = cursor_budget.admit_next_cursor(next_cursor)?;
if request_cursor.is_some() && request_cursor == validated_next_cursor.as_deref() {
return Err(McpError::internal_error(
PAGINATION_CURSOR_NO_PROGRESS_ERROR,
));
}
let peer_has_more = validated_next_cursor.is_some();
let next_cursor = if local_truncated {
None
} else {
validated_next_cursor
};
Ok(BoundedListPage {
items: retained,
next_cursor,
local_truncated,
peer_has_more,
})
}
fn bounded_cursor_parameter(cursor: Option<&str>) -> McpResult<Option<String>> {
let Some(cursor) = cursor else {
return Ok(None);
};
if cursor.len() > MAX_PAGINATION_CURSOR_BYTES {
return Err(McpError::invalid_params(PAGINATION_CURSOR_LIMIT_ERROR));
}
Ok(Some(cursor.to_owned()))
}
fn validate_list_page_request(
cursor: Option<&str>,
limits: ListPageLimits,
) -> McpResult<Option<String>> {
limits.validate()?;
bounded_cursor_parameter(cursor)
}
const REMOTE_LOG_TARGET: &str = "fastmcp_rust::remote";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MetadataSizeBucket {
Empty,
Small,
Medium,
Large,
Oversized,
}
impl MetadataSizeBucket {
const fn for_extent(extent: usize) -> Self {
match extent {
0 => Self::Empty,
1..=64 => Self::Small,
65..=1_024 => Self::Medium,
1_025..=65_536 => Self::Large,
_ => Self::Oversized,
}
}
const fn as_str(self) -> &'static str {
match self {
Self::Empty => "empty",
Self::Small => "small",
Self::Medium => "medium",
Self::Large => "large",
Self::Oversized => "oversized",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct RemoteLogMetadata {
level: &'static str,
logger_present: bool,
logger_bytes: MetadataSizeBucket,
data_kind: &'static str,
data_extent: MetadataSizeBucket,
}
impl std::fmt::Display for RemoteLogMetadata {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"remote_log level={} logger_present={} logger_bytes={} data_kind={} data_extent={}",
self.level,
self.logger_present,
self.logger_bytes.as_str(),
self.data_kind,
self.data_extent.as_str()
)
}
}
fn remote_log_metadata(message: &LogMessageParams) -> RemoteLogMetadata {
let level = match message.level {
LogLevel::Debug => "debug",
LogLevel::Info => "info",
LogLevel::Notice => "notice",
LogLevel::Warning => "warning",
LogLevel::Error => "error",
LogLevel::Critical => "critical",
LogLevel::Alert => "alert",
LogLevel::Emergency => "emergency",
};
let (data_kind, data_extent) = match &message.data {
serde_json::Value::Null => ("null", 0),
serde_json::Value::Bool(_) => ("boolean", 1),
serde_json::Value::Number(_) => ("number", 1),
serde_json::Value::String(value) => ("string", value.len()),
serde_json::Value::Array(values) => ("array", values.len()),
serde_json::Value::Object(values) => ("object", values.len()),
};
RemoteLogMetadata {
level,
logger_present: message.logger.is_some(),
logger_bytes: MetadataSizeBucket::for_extent(
message.logger.as_ref().map_or(0, String::len),
),
data_kind,
data_extent: MetadataSizeBucket::for_extent(data_extent),
}
}
#[derive(Debug, Clone)]
struct ReceivedJsonRpcResponse {
response: JsonRpcResponse,
raw_result: Option<String>,
}
/// Selects the cancellation election used after a final response has already
/// been correlated to its request-local waiter.
///
/// Final task creation is the one exception to the ordinary cancellation-first
/// rule: once the peer has committed a valid `tools/call` task handle, dropping
/// it would strand an externally durable task without a client-owned handle.
#[derive(Clone)]
enum RequestCancellationTerminalElection {
CancelFirst,
#[cfg(feature = "tasks")]
FinalToolsCallTask {
request: Box<CoreRequest>,
},
}
impl RequestCancellationTerminalElection {
fn response_wins(
self,
#[cfg_attr(not(feature = "tasks"), allow(unused_variables))]
response: &ReceivedJsonRpcResponse,
) -> bool {
match self {
Self::CancelFirst => false,
#[cfg(feature = "tasks")]
Self::FinalToolsCallTask { request } => {
response.response.error.is_none()
&& response.response.result.as_ref().is_some_and(|result| {
response.raw_result.as_deref().is_some_and(|source| {
matches!(
decode_core_result_with_cache_ttl_from_source(
&request,
result,
Some(source),
),
Ok((CoreResult::Final(FinalCoreResult::ToolsCallTask { .. }), _))
)
})
})
}
}
}
}
impl std::ops::Deref for ReceivedJsonRpcResponse {
type Target = JsonRpcResponse;
fn deref(&self) -> &Self::Target {
&self.response
}
}
type CorrelatedResponse = McpResult<ReceivedJsonRpcResponse>;
/// The receive half owned by exactly one registered request.
///
/// The client's single transport receive loop is the only sender. An
/// asupersync oneshot retains a reordered response until this waiter is polled
/// and wakes an already-polled waiter when the response or a connection-wide
/// error arrives.
#[derive(Debug)]
struct ResponseWaiter {
id: RequestId,
receiver: oneshot::Receiver<CorrelatedResponse>,
}
impl ResponseWaiter {
fn try_response(&mut self) -> McpResult<Option<ReceivedJsonRpcResponse>> {
match self.receiver.try_recv() {
Ok(Ok(response)) => Ok(Some(response)),
Ok(Err(error)) => Err(error),
Err(oneshot::TryRecvError::Empty) => Ok(None),
Err(oneshot::TryRecvError::Closed) => Err(McpError::internal_error(
"Response waiter closed without a terminal outcome",
)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResponseRoute {
Delivered,
TombstoneRetired,
InvalidEnvelope,
UnknownId,
MissingId,
WaiterDropped,
ConnectionClosed,
}
/// Correlation state owned by the single-reader stdio client.
///
/// Only registered IDs can receive a response. A committed request timeout
/// replaces its waiter with a bounded tombstone, so the exact late response is
/// consumed without being misclassified or waking another owner. Duplicate
/// and unknown-ID responses cannot replace a terminal outcome. This does not
/// make the current `&mut Client` API concurrent; it makes correlation lossless
/// for every ID registered with the one receive loop and provides bounded
/// state for a future multiplexed adapter.
struct ResponseRegistry {
pending: std::collections::HashMap<CorrelationKey, oneshot::Sender<CorrelatedResponse>>,
tombstones: std::collections::HashMap<CorrelationKey, Instant>,
/// Live local owners whose one permitted cancellation control has been
/// claimed. This remains distinct from response tombstones so a timeout
/// can retain its late-response guard after its outbound control write.
cancellation_controls: std::collections::HashMap<CorrelationKey, Instant>,
terminal_error: Option<McpError>,
uncorrelated_diagnostics: u8,
}
impl ResponseRegistry {
fn new() -> Self {
Self {
pending: std::collections::HashMap::new(),
tombstones: std::collections::HashMap::new(),
cancellation_controls: std::collections::HashMap::new(),
terminal_error: None,
uncorrelated_diagnostics: 0,
}
}
fn register(&mut self, id: RequestId) -> McpResult<ResponseWaiter> {
self.prune_expired_retained_state(Instant::now());
if let Some(error) = &self.terminal_error {
return Err(error.clone());
}
let key = id
.correlation_key()
.map_err(|_| McpError::internal_error("Invalid JSON-RPC request ID"))?;
if self.pending.contains_key(&key) {
return Err(McpError::internal_error("Duplicate in-flight request ID"));
}
if self.tombstones.contains_key(&key) {
return Err(McpError::internal_error(
"Retired request ID cannot be reused",
));
}
if self.pending.len() >= MAX_IN_FLIGHT_RESPONSES {
return Err(McpError::internal_error(
"Client in-flight response limit reached",
));
}
if self.pending.len().saturating_add(self.tombstones.len()) >= MAX_RESPONSE_CORRELATIONS {
return Err(McpError::internal_error(
"Client response correlation limit reached",
));
}
let (sender, receiver) = oneshot::channel();
// A new local owner begins a fresh cancellation-control generation.
self.cancellation_controls.remove(&key);
self.pending.insert(key, sender);
Ok(ResponseWaiter { id, receiver })
}
fn route(&mut self, response: JsonRpcResponse) -> ResponseRoute {
self.route_with_raw_result(response, None)
}
fn route_with_raw_result(
&mut self,
response: JsonRpcResponse,
raw_result: Option<String>,
) -> ResponseRoute {
self.prune_expired_retained_state(Instant::now());
if self.terminal_error.is_some() {
self.note_uncorrelated_response("response received after connection failure");
return ResponseRoute::ConnectionClosed;
}
if let Err(error) = validate_response_envelope(&response) {
self.fail_all(error);
return ResponseRoute::InvalidEnvelope;
}
let Some(id) = response.id.clone() else {
let error = McpError::internal_error("Server response is missing a request ID");
self.fail_all(error);
return ResponseRoute::MissingId;
};
let Ok(key) = id.correlation_key() else {
self.fail_all(McpError::internal_error(INVALID_RESPONSE_ENVELOPE_ERROR));
return ResponseRoute::InvalidEnvelope;
};
if self.tombstones.remove(&key).is_some() {
return ResponseRoute::TombstoneRetired;
}
let Some(sender) = self.pending.remove(&key) else {
self.note_uncorrelated_response("response received for unknown or completed request");
return ResponseRoute::UnknownId;
};
match sender.send_blocking(Ok(ReceivedJsonRpcResponse {
response,
raw_result,
})) {
Ok(()) => ResponseRoute::Delivered,
Err(_) => {
self.note_uncorrelated_response("response owner was already dropped");
ResponseRoute::WaiterDropped
}
}
}
fn fail(&mut self, id: &RequestId, error: McpError) -> bool {
let Ok(key) = id.correlation_key() else {
return false;
};
let Some(sender) = self.pending.remove(&key) else {
return false;
};
let _ = sender.send_blocking(Err(error));
true
}
fn tombstone(&mut self, id: &RequestId, error: McpError) -> McpResult<bool> {
let now = Instant::now();
self.prune_expired_retained_state(now);
if let Some(terminal_error) = &self.terminal_error {
return Err(terminal_error.clone());
}
let key = id
.correlation_key()
.map_err(|_| McpError::internal_error("Invalid JSON-RPC request ID"))?;
if self.tombstones.contains_key(&key) || !self.pending.contains_key(&key) {
return Ok(false);
}
if self.tombstones.len() >= MAX_RESPONSE_CORRELATIONS {
return Err(McpError::internal_error(
"Client response tombstone limit reached",
));
}
let expires_at = now
.checked_add(RESPONSE_TOMBSTONE_RETENTION)
.ok_or_else(|| McpError::internal_error("Tombstone retention exceeds clock range"))?;
let Some(sender) = self.pending.remove(&key) else {
return Ok(false);
};
self.tombstones.insert(key, expires_at);
let _ = sender.send_blocking(Err(error));
Ok(true)
}
/// Removes a registered request which was cancelled before any frame was
/// committed. Unlike a tombstone, this retains no late-response state:
/// the peer cannot have observed an uncommitted request.
fn abandon_before_commit(&mut self, id: &RequestId) -> bool {
let Ok(key) = id.correlation_key() else {
return false;
};
self.pending.remove(&key).is_some()
}
/// Claims the sole cancellation-control attempt for `id`.
///
/// The claim occurs before transport delivery. While the connection stays
/// live, retrying the public API or racing a later local timeout is therefore
/// an at-most-once no-op. Delivery failure terminates the connection, whose
/// terminal cleanup may then release all retained markers.
fn claim_cancellation_control(&mut self, id: &RequestId) -> McpResult<bool> {
let now = Instant::now();
self.prune_expired_retained_state(now);
if let Some(terminal_error) = &self.terminal_error {
return Err(terminal_error.clone());
}
let key = id
.correlation_key()
.map_err(|_| McpError::internal_error("Invalid JSON-RPC request ID"))?;
if self.cancellation_controls.contains_key(&key) {
return Ok(false);
}
if self.cancellation_controls.len() >= MAX_CANCELLATION_CONTROL_IDS {
return Err(McpError::internal_error(
"Client cancellation-control retention limit reached",
));
}
let expires_at = now
.checked_add(CANCELLATION_CONTROL_RETENTION)
.ok_or_else(|| {
McpError::internal_error("Cancellation-control retention exceeds clock range")
})?;
self.cancellation_controls.insert(key, expires_at);
Ok(true)
}
/// Returns whether this client currently owns a live request ID.
fn owns_live_request(&mut self, id: &RequestId) -> McpResult<bool> {
self.prune_expired_retained_state(Instant::now());
if let Some(terminal_error) = &self.terminal_error {
return Err(terminal_error.clone());
}
let key = id
.correlation_key()
.map_err(|_| McpError::internal_error("Invalid JSON-RPC request ID"))?;
Ok(self.pending.contains_key(&key))
}
fn prune_expired_retained_state(&mut self, now: Instant) {
self.tombstones.retain(|_, expires_at| *expires_at > now);
self.cancellation_controls
.retain(|_, expires_at| *expires_at > now);
}
fn fail_all(&mut self, error: McpError) -> usize {
self.tombstones.clear();
self.cancellation_controls.clear();
if self.terminal_error.is_some() {
return 0;
}
self.terminal_error = Some(error.clone());
let mut failed = 0;
for (_, sender) in self.pending.drain() {
let _ = sender.send_blocking(Err(error.clone()));
failed += 1;
}
failed
}
fn note_uncorrelated_response(&mut self, reason: &'static str) {
if self.uncorrelated_diagnostics < MAX_UNCORRELATED_RESPONSE_DIAGNOSTICS {
self.uncorrelated_diagnostics += 1;
log::warn!("Discarding uncorrelated MCP response: {reason}");
}
}
fn terminal_error(&self) -> Option<McpError> {
self.terminal_error.clone()
}
#[cfg(test)]
fn pending_len(&self) -> usize {
self.pending.len()
}
#[cfg(test)]
fn tombstone_len(&self) -> usize {
self.tombstones.len()
}
#[cfg(test)]
fn cancellation_control_len(&self) -> usize {
self.cancellation_controls.len()
}
}
impl Default for ResponseRegistry {
fn default() -> Self {
Self::new()
}
}
/// Shared ownership of the one stdio response-correlation registry.
///
/// Request commitment may occur through a cloneable negotiated executor, but
/// all response delivery still belongs to the client's sole ingress arbiter.
#[derive(Clone, Default)]
struct SharedResponseRegistry(Arc<Mutex<ResponseRegistry>>);
impl std::fmt::Debug for SharedResponseRegistry {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.debug_tuple("SharedResponseRegistry").finish()
}
}
impl SharedResponseRegistry {
fn new() -> Self {
Self(Arc::new(Mutex::new(ResponseRegistry::new())))
}
fn lock(&self) -> McpResult<std::sync::MutexGuard<'_, ResponseRegistry>> {
self.0
.lock()
.map_err(|_| McpError::internal_error("Client response registry is unavailable"))
}
fn ptr_eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0)
}
fn register(&self, id: RequestId) -> McpResult<ResponseWaiter> {
self.lock()?.register(id)
}
#[cfg(test)]
fn route(&self, response: JsonRpcResponse) -> ResponseRoute {
self.lock()
.map_or(ResponseRoute::ConnectionClosed, |mut registry| {
registry.route(response)
})
}
fn route_with_raw_result(
&self,
response: JsonRpcResponse,
raw_result: Option<String>,
) -> ResponseRoute {
self.lock()
.map_or(ResponseRoute::ConnectionClosed, |mut registry| {
registry.route_with_raw_result(response, raw_result)
})
}
fn fail(&self, id: &RequestId, error: McpError) -> bool {
self.lock()
.is_ok_and(|mut registry| registry.fail(id, error))
}
fn tombstone(&self, id: &RequestId, error: McpError) -> McpResult<bool> {
self.lock()?.tombstone(id, error)
}
fn abandon_before_commit(&self, id: &RequestId) -> bool {
self.lock()
.is_ok_and(|mut registry| registry.abandon_before_commit(id))
}
fn claim_cancellation_control(&self, id: &RequestId) -> McpResult<bool> {
self.lock()?.claim_cancellation_control(id)
}
fn owns_live_request(&self, id: &RequestId) -> McpResult<bool> {
self.lock()?.owns_live_request(id)
}
fn fail_all(&self, error: McpError) -> usize {
self.lock()
.map_or(0, |mut registry| registry.fail_all(error))
}
fn terminal_error(&self) -> Option<McpError> {
self.lock()
.ok()
.and_then(|registry| registry.terminal_error())
}
#[cfg(test)]
fn pending_len(&self) -> usize {
self.lock().map_or(0, |registry| registry.pending_len())
}
#[cfg(test)]
fn tombstone_len(&self) -> usize {
self.lock().map_or(0, |registry| registry.tombstone_len())
}
#[cfg(test)]
fn cancellation_control_len(&self) -> usize {
self.lock()
.map_or(0, |registry| registry.cancellation_control_len())
}
#[cfg(test)]
fn uncorrelated_diagnostics(&self) -> u8 {
self.lock()
.map_or(0, |registry| registry.uncorrelated_diagnostics)
}
}
fn invoke_tool_progress_callback(
callback: ProgressCallback<'_>,
progress: f64,
total: Option<f64>,
message: Option<&str>,
) -> McpResult<()> {
catch_client_callback_unwind(|| {
callback(progress, total, message);
})
.map_err(|_| McpError::internal_error(PROGRESS_CALLBACK_PANIC_ERROR))
}
/// A ready dual-era HTTP MCP client.
///
/// This composes the policy-bound HTTP connection with the legacy lifecycle
/// required after an SSE fallback. Modern connections are ready after their
/// successful `server/discover` probe; legacy connections are ready only once
/// this type has completed `initialize` and `notifications/initialized`.
pub struct HttpClient {
connection: ClientHttpConnection,
client_info: ClientInfo,
client_capabilities: ClientCapabilities,
server_info: ServerInfo,
legacy_server_capabilities: Option<ServerCapabilities>,
/// Handshake instructions retained from modern discovery or exact-2024
/// initialize. `None` means the peer did not advertise instructions.
instructions: Option<String>,
next_id: AtomicU64,
final_result_cache: FinalResultCache,
final_cache_ttl_diagnostics: VecDeque<FinalCacheTtlDiagnostic>,
mcp_apps_settings: Option<McpAppsClientSettings>,
/// At most one incremental HTTP catalog listener. Stored on the client so
/// ordinary requests can keep using this connection while events are
/// drained one at a time.
live_subscription: Option<ModernHttpSubscriptionListener>,
/// At most one incremental official Tasks listener. HTTP catalog listen
/// and Tasks listen use separate SSE POSTs, so both may be live.
#[cfg(feature = "tasks")]
live_task_subscription: Option<ModernHttpSubscriptionListener>,
/// Modern request-only `io.modelcontextprotocol/logLevel`. Absent until
/// [`Self::set_log_level_typed`] stores a severity; never sent as the
/// removed final `logging/setLevel` RPC.
final_log_level: Option<LoggingLevel>,
/// Request-scoped final server notifications retained from modern SSE
/// response bodies. Progress is kept in a separate queue so exact JSON
/// number lexemes survive.
final_server_notifications: VecDeque<ServerNotification>,
final_progress_notifications: VecDeque<FinalProgressNotificationParams>,
}
/// Errors raised while composing a ready public HTTP client.
#[derive(Debug)]
pub enum HttpClientError {
/// The policy-bound HTTP connection could not be established or used.
Connection(ClientHttpConnectionError),
/// A modern discovery response omitted its required server identity.
ModernDiscoveryMissingServerInfo,
/// The legacy initialization response carried a JSON-RPC error.
LegacyInitializationRejected,
/// The legacy initialization response had no result payload.
LegacyInitializationMissingResult,
/// The legacy initialization result did not have the exact required shape.
LegacyInitializationInvalidResult,
/// The legacy peer selected a protocol version other than 2024-11-05.
LegacyInitializationUnsupportedProtocolVersion { actual: String },
/// The HTTP request-ID space was exhausted.
RequestIdExhausted,
/// A request or response did not match the selected core-result contract.
CoreResult(McpError),
}
impl From<McpError> for HttpClientError {
fn from(error: McpError) -> Self {
Self::CoreResult(error)
}
}
impl std::fmt::Display for HttpClientError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Connection(error) => error.fmt(formatter),
Self::ModernDiscoveryMissingServerInfo => {
formatter.write_str("modern server/discover response has no server identity")
}
Self::LegacyInitializationRejected => {
formatter.write_str("legacy initialize request was rejected")
}
Self::LegacyInitializationMissingResult => {
formatter.write_str("legacy initialize response has no result")
}
Self::LegacyInitializationInvalidResult => {
formatter.write_str("legacy initialize response has an invalid result")
}
Self::LegacyInitializationUnsupportedProtocolVersion { actual } => write!(
formatter,
"legacy initialize selected unsupported protocol version {actual}"
),
Self::RequestIdExhausted => {
formatter.write_str("HTTP client request IDs are exhausted")
}
Self::CoreResult(error) => error.fmt(formatter),
}
}
}
impl std::error::Error for HttpClientError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Connection(error) => Some(error),
Self::ModernDiscoveryMissingServerInfo
| Self::LegacyInitializationRejected
| Self::LegacyInitializationMissingResult
| Self::LegacyInitializationInvalidResult
| Self::LegacyInitializationUnsupportedProtocolVersion { .. }
| Self::RequestIdExhausted => None,
Self::CoreResult(error) => Some(error),
}
}
}
/// A live final HTTP subscription listener bound to one [`HttpClient`] cache.
///
/// Each accepted catalog or resource event advances the owning client's cache
/// generation before [`Self::next_event`] returns it. The listener holds the
/// cache borrow for its lifetime, so callers cannot issue a cacheable request
/// against the same client between receiving an event and observing its
/// invalidation.
pub struct HttpSubscriptionListener<'client> {
listener: ModernHttpSubscriptionListener,
final_result_cache: &'client mut FinalResultCache,
}
impl HttpSubscriptionListener<'_> {
/// Returns the JSON-RPC request ID that owns this listener.
#[must_use]
pub const fn request_id(&self) -> &RequestId {
self.listener.request_id()
}
/// Returns the acknowledged filter once the first stream record is admitted.
#[must_use]
pub const fn accepted_filter(&self) -> Option<&SubscriptionFilter> {
self.listener.accepted_filter()
}
/// Returns the current cache counters while this listener owns the cache.
#[must_use]
pub const fn final_result_cache_stats(&self) -> FinalCacheStats {
self.final_result_cache.stats()
}
/// Reads one live subscription record and immediately invalidates accepted
/// catalog or resource result sets before yielding that record.
pub async fn next_event(
&mut self,
cx: &Cx,
) -> Result<Option<ModernHttpSubscriptionListenEvent>, HttpClientError> {
let event = self.listener.next_event(cx).await.map_err(|error| {
HttpClientError::Connection(ClientHttpConnectionError::SubscriptionsListen(error))
})?;
if let Some(ModernHttpSubscriptionListenEvent::Notification(notification)) = event.as_ref()
{
self.final_result_cache
.invalidate_notification(notification);
}
Ok(event)
}
/// Collects the remaining live records into the established terminal record.
pub async fn collect(
mut self,
cx: &Cx,
) -> Result<ModernHttpSubscriptionListenCollector, HttpClientError> {
let mut notifications = Vec::new();
#[cfg(feature = "tasks")]
let mut task_notifications = Vec::new();
loop {
let event = self.next_event(cx).await?.ok_or_else(|| {
HttpClientError::CoreResult(McpError::invalid_request(
"HTTP subscriptions listener ended after its terminal result",
))
})?;
match event {
ModernHttpSubscriptionListenEvent::Acknowledged { .. } => {}
ModernHttpSubscriptionListenEvent::Notification(notification) => {
notifications.push(notification);
}
#[cfg(feature = "tasks")]
ModernHttpSubscriptionListenEvent::TaskNotification(notification) => {
task_notifications.push(notification);
}
ModernHttpSubscriptionListenEvent::Terminal {
subscription_id,
result: terminal,
} => {
let accepted_filter =
self.listener.accepted_filter().cloned().ok_or_else(|| {
HttpClientError::CoreResult(McpError::invalid_request(
"HTTP subscriptions listener terminated before acknowledgement",
))
})?;
return Ok(ModernHttpSubscriptionListenCollector {
subscription_id,
accepted_filter,
notifications,
#[cfg(feature = "tasks")]
task_notifications,
terminal,
});
}
}
}
}
}
/// A durable final Tasks snapshot attached to one HTTP client on demand.
///
/// The handle intentionally owns only the latest admitted task snapshot. Each
/// operation receives the owning [`HttpClient`] so that every wire request
/// continues to use that client's sole monotonic JSON-RPC ID allocator.
#[cfg(feature = "tasks")]
#[derive(Clone, Debug)]
pub struct FinalTaskHandle {
task: FinalTask,
}
#[cfg(feature = "tasks")]
impl FinalTaskHandle {
fn new(task: FinalTask) -> Self {
Self { task }
}
/// Returns the last task snapshot admitted for this handle.
#[must_use]
pub const fn task(&self) -> &FinalTask {
&self.task
}
/// Returns this handle's opaque durable task identifier.
#[must_use]
pub fn task_id(&self) -> &FinalTaskId {
&self.task.base().task_id
}
/// Reads the authoritative current snapshot through `tasks/get`.
///
/// The stored snapshot changes only after the response has passed the
/// exact task-ID check in the HTTP Tasks decoder.
pub async fn poll(
&mut self,
cx: &Cx,
client: &mut HttpClient,
) -> Result<&FinalTask, HttpClientError> {
let task = client
.get_final_task_snapshot(cx, self.task_id().clone())
.await?;
self.task = task;
Ok(&self.task)
}
/// Supplies input for an `input_required` task.
///
/// A successful `tasks/update` result is only an empty acknowledgement;
/// it does not contain a replacement task snapshot. Use [`Self::poll`] or
/// [`Self::watch`] to observe the server's subsequent task state.
pub async fn resume_input(
&mut self,
cx: &Cx,
client: &mut HttpClient,
input_responses: FinalTaskInputResponses,
) -> Result<FinalUpdateTaskResult, HttpClientError> {
client
.submit_final_task_input(cx, &self.task, input_responses)
.await
}
/// Requests cancellation for this exact durable task.
///
/// A successful `tasks/cancel` result is only an empty acknowledgement;
/// it does not replace this handle's last admitted snapshot. Use
/// [`Self::poll`] or [`Self::watch`] to observe the resulting task state.
pub async fn cancel(
&self,
cx: &Cx,
client: &mut HttpClient,
) -> Result<FinalCancelTaskResult, HttpClientError> {
client.cancel_final_task(cx, self.task_id().clone()).await
}
/// Opens one live `notifications/tasks` stream for exactly this task.
///
/// The resulting watcher is caller-driven and does not reconnect or poll
/// after stream termination. The Tasks extension exposes neither a replay
/// cursor nor a resumption token, so a new watch must begin from an
/// explicit [`Self::poll`] reconciliation.
pub async fn watch<'client, 'handle>(
&'handle mut self,
cx: &Cx,
client: &'client mut HttpClient,
limits: sse::SseLimits,
) -> Result<FinalTaskWatch<'client, 'handle>, HttpClientError> {
let mut notifications = SubscriptionFilter::default();
fastmcp_protocol::set_task_subscription_ids(
&mut notifications,
vec![self.task_id().clone()],
)
.map_err(|_| {
HttpClientError::CoreResult(McpError::internal_error(
"final task handle could not compose its task subscription filter",
))
})?;
let listener = client
.open_subscriptions_listener(cx, notifications, limits)
.await?;
Ok(FinalTaskWatch {
listener,
handle: self,
})
}
}
/// One high-level event from a [`FinalTaskHandle`] watcher.
#[cfg(feature = "tasks")]
#[derive(Debug, Clone)]
pub enum FinalTaskWatchEvent {
/// The server acknowledged the exact task filter requested by the watcher.
Acknowledged {
/// The task-only subscription filter accepted by the server.
accepted_filter: SubscriptionFilter,
},
/// A full task snapshot replaced the handle's previous snapshot.
TaskUpdated(FinalTaskStatusNotification),
/// The request-owned subscription stream terminated normally.
Terminal {
/// The JSON-RPC ID that owns the terminating subscription result.
subscription_id: RequestId,
/// The typed final subscription result.
result: CompleteResult<FinalSubscriptionsListenResult>,
},
}
/// A live task-only HTTP subscription bound to one mutable task handle.
///
/// Holding this watcher prevents concurrent polling or input submission with
/// the same [`HttpClient`] and handle, preserving the client's existing
/// request-ID ownership and a single ordered task snapshot.
#[cfg(feature = "tasks")]
pub struct FinalTaskWatch<'client, 'handle> {
listener: HttpSubscriptionListener<'client>,
handle: &'handle mut FinalTaskHandle,
}
#[cfg(feature = "tasks")]
impl FinalTaskWatch<'_, '_> {
/// Reads one task watcher event and applies each admitted task snapshot.
///
/// `None` is returned only after the terminal event was already yielded.
pub async fn next_event(
&mut self,
cx: &Cx,
) -> Result<Option<FinalTaskWatchEvent>, HttpClientError> {
let event = self.listener.next_event(cx).await?;
match event {
None => Ok(None),
Some(ModernHttpSubscriptionListenEvent::Acknowledged { accepted_filter }) => {
let accepted_task_ids = task_subscription_ids(&accepted_filter)
.map_err(|_| {
HttpClientError::CoreResult(McpError::invalid_request(
"final task subscription acknowledgement has invalid task IDs",
))
})?
.ok_or_else(|| {
HttpClientError::CoreResult(McpError::invalid_request(
"final task subscription acknowledgement omitted the requested task",
))
})?;
if accepted_task_ids.len() != 1
|| accepted_task_ids.first() != Some(self.handle.task_id())
{
return Err(HttpClientError::CoreResult(McpError::invalid_request(
"final task subscription acknowledgement did not accept the requested task",
)));
}
Ok(Some(FinalTaskWatchEvent::Acknowledged { accepted_filter }))
}
Some(ModernHttpSubscriptionListenEvent::TaskNotification(notification)) => {
if ¬ification.params.task.base().task_id != self.handle.task_id() {
return Err(HttpClientError::CoreResult(McpError::invalid_request(
"final task notification does not match this handle",
)));
}
self.handle.task = notification.params.task.clone();
Ok(Some(FinalTaskWatchEvent::TaskUpdated(notification)))
}
Some(ModernHttpSubscriptionListenEvent::Terminal {
subscription_id,
result,
}) => Ok(Some(FinalTaskWatchEvent::Terminal {
subscription_id,
result,
})),
Some(ModernHttpSubscriptionListenEvent::Notification(_)) => {
Err(HttpClientError::CoreResult(McpError::invalid_request(
"final task watcher received a non-Tasks subscription event",
)))
}
}
}
}
impl HttpClient {
/// Connects one immutable HTTP plan and completes the selected era's
/// required lifecycle before exposing the client.
pub async fn connect(
cx: &Cx,
protocol_plan: ClientProtocolPlan,
client_info: ClientInfo,
client_capabilities: ClientCapabilities,
) -> Result<Self, HttpClientError> {
validate_protocol_plan_feature(&protocol_plan).map_err(HttpClientError::CoreResult)?;
Self::connect_with_mcp_apps(
cx,
protocol_plan,
client_info,
client_capabilities,
None,
ReverseRequestHandlers::new(),
)
.await
}
pub(crate) async fn connect_with_mcp_apps(
cx: &Cx,
protocol_plan: ClientProtocolPlan,
client_info: ClientInfo,
client_capabilities: ClientCapabilities,
mcp_apps_settings: Option<McpAppsClientSettings>,
reverse_request_handlers: ReverseRequestHandlers,
) -> Result<Self, HttpClientError> {
Self::connect_with_extensions(
cx,
protocol_plan,
client_info,
client_capabilities,
mcp_apps_settings,
None,
reverse_request_handlers,
)
.await
}
pub(crate) async fn connect_with_extensions(
cx: &Cx,
protocol_plan: ClientProtocolPlan,
client_info: ClientInfo,
client_capabilities: ClientCapabilities,
mcp_apps_settings: Option<McpAppsClientSettings>,
client_extension_runtime: Option<Arc<ClientExtensionRuntime>>,
reverse_request_handlers: ReverseRequestHandlers,
) -> Result<Self, HttpClientError> {
let mut connection = ClientHttpConnection::connect_with_extensions(
cx,
protocol_plan,
client_info.clone(),
client_capabilities.clone(),
mcp_apps_settings.clone(),
client_extension_runtime,
)
.await
.map_err(HttpClientError::Connection)?;
#[cfg(feature = "legacy-2024-11-05")]
let mut client_capabilities = client_capabilities;
if connection.selected_protocol_era() == ProtocolEra::Modern2026
&& reverse_request_handlers.has_modern_handlers()
{
connection
.set_modern_reverse_request_handlers(reverse_request_handlers.clone())
.map_err(HttpClientError::CoreResult)?;
}
// Legacy callbacks are part of the capability set advertised by
// `initialize`, so they must be installed before the lifecycle sends
// that request. Auto reaches this same branch only after it has
// selected the exact legacy SSE route; modern never receives these
// legacy method handlers.
#[cfg(feature = "legacy-2024-11-05")]
if connection.selected_protocol_era() == ProtocolEra::Legacy2024 {
reverse_request_handlers.derive_legacy_capabilities(&mut client_capabilities);
reverse_request_handlers
.validate_legacy_capabilities(&client_capabilities)
.map_err(HttpClientError::CoreResult)?;
connection.set_legacy_client_capabilities(client_capabilities.clone());
connection
.set_legacy_reverse_request_handlers(reverse_request_handlers)
.map_err(HttpClientError::CoreResult)?;
// The SSE reader must own ingress before `initialize`: a server is
// permitted to issue an exact-2024 reverse request during the
// bootstrap exchange, and its cancellation must use the same
// connection-owned registry as ready-state callbacks.
connection
.start_legacy_receive_pump(cx)
.map_err(HttpClientError::Connection)?;
}
let (server_info, legacy_server_capabilities, instructions) =
match connection.selected_protocol_era() {
ProtocolEra::Modern2026 => {
let discovery = connection.server_discovery();
let server_info = discovery
.as_ref()
.and_then(|discovery| discovery.server_info().cloned())
.ok_or(HttpClientError::ModernDiscoveryMissingServerInfo)?;
let instructions = discovery.and_then(|discovery| {
discovery
.instructions()
.map(|instructions| instructions.as_str().to_owned())
});
(server_info, None, instructions)
}
#[cfg(feature = "legacy-2024-11-05")]
ProtocolEra::Legacy2024 => {
let parameters = serde_json::to_value(InitializeParams {
protocol_version: PROTOCOL_VERSION.to_owned(),
capabilities: client_capabilities.clone(),
client_info: client_info.clone(),
})
.map_err(|_| HttpClientError::LegacyInitializationInvalidResult)?;
let response = connection
.request(cx, "initialize", parameters, RequestId::Number(1))
.await
.map_err(HttpClientError::Connection)?;
let ClientHttpResponse::Legacy(JsonRpcMessage::Response(response)) = response
else {
return Err(HttpClientError::LegacyInitializationInvalidResult);
};
if response.error.is_some() {
return Err(HttpClientError::LegacyInitializationRejected);
}
let value = response
.result
.ok_or(HttpClientError::LegacyInitializationMissingResult)?;
let initialization = serde_json::from_value::<InitializeResult>(value)
.map_err(|_| HttpClientError::LegacyInitializationInvalidResult)?;
if initialization.protocol_version != PROTOCOL_VERSION {
return Err(
HttpClientError::LegacyInitializationUnsupportedProtocolVersion {
actual: initialization.protocol_version,
},
);
}
connection.record_legacy_negotiated_protocol_version(
initialization.protocol_version.clone(),
);
connection
.notify(cx, "notifications/initialized", None)
.await
.map_err(HttpClientError::Connection)?;
(
initialization.server_info,
Some(initialization.capabilities),
initialization.instructions,
)
}
#[cfg(not(feature = "legacy-2024-11-05"))]
ProtocolEra::Legacy2024 => {
return Err(HttpClientError::CoreResult(McpError::invalid_params(
"MCP 2024-11-05 HTTP requires the legacy-2024-11-05 feature",
)));
}
};
Ok(Self {
connection,
client_info,
client_capabilities,
server_info,
legacy_server_capabilities,
instructions,
next_id: AtomicU64::new(2),
final_result_cache: FinalResultCache::default(),
final_cache_ttl_diagnostics: VecDeque::new(),
mcp_apps_settings,
live_subscription: None,
#[cfg(feature = "tasks")]
live_task_subscription: None,
final_log_level: None,
final_server_notifications: VecDeque::new(),
final_progress_notifications: VecDeque::new(),
})
}
/// Returns the negotiated protocol era.
#[must_use]
pub const fn selected_protocol_era(&self) -> ProtocolEra {
self.connection.selected_protocol_era()
}
/// Retains modern Implementation extras for later request `_meta` stamps.
pub fn set_client_implementation(
&mut self,
implementation: fastmcp_protocol::common_types::Implementation,
) {
self.connection.set_client_implementation(implementation);
}
/// Returns whether final discovery activated the official MCP Apps extension.
#[cfg(feature = "apps")]
#[must_use]
pub fn mcp_apps_active(&self) -> bool {
self.connection.mcp_apps_active()
}
#[cfg(feature = "apps")]
fn current_mcp_apps_activation_receipt(
&self,
) -> Option<fastmcp_protocol::extensions::McpAppsActivationReceipt> {
self.connection.mcp_apps_activation_receipt()
}
/// Returns the frozen generic extension set retained from the modern
/// `server/discover` exchange, if a builder registry was configured.
#[must_use]
pub fn negotiated_extensions(
&self,
) -> Option<fastmcp_protocol::extensions::NegotiatedExtensionSet> {
self.connection.negotiated_extensions()
}
/// Starts one browser-agnostic Apps Host for a negotiated modern connection.
/// The embedder owns the View carrier and rendering policy.
#[cfg(feature = "apps")]
pub fn mcp_apps_host<T, P>(
&self,
transport: T,
configuration: McpAppsHostConfiguration,
policy: P,
) -> Result<McpAppsHost<T, P>, McpAppsHostError>
where
T: McpAppsBridgeTransport,
P: McpAppsHostPolicy,
{
let activation_receipt = self.current_mcp_apps_activation_receipt();
let activation_proof =
mcp_apps::McpAppsActivationProof::from_activation_receipt(activation_receipt.as_ref())?;
Ok(McpAppsHost::new_negotiated(
transport,
configuration,
policy,
activation_proof,
))
}
/// Starts the closed JSON-RPC Apps bridge on this ready modern HTTP
/// connection. Reused View methods allocate fresh HTTP core request IDs.
#[cfg(feature = "apps")]
pub fn mcp_apps_wire_host<T>(
&mut self,
transport: T,
configuration: mcp_apps::McpAppsWireHostConfiguration,
) -> Result<
mcp_apps::McpAppsWireHost<T, mcp_apps::McpAppsHttpClientWirePolicy<'_>>,
McpAppsHostError,
>
where
T: mcp_apps::McpAppsWireBridgeTransport,
{
let activation_receipt = self.current_mcp_apps_activation_receipt();
let activation_proof =
mcp_apps::McpAppsActivationProof::from_activation_receipt(activation_receipt.as_ref())?;
Ok(mcp_apps::McpAppsWireHost::new_negotiated(
transport,
configuration,
mcp_apps::McpAppsHttpClientWirePolicy::new(self),
activation_proof,
))
}
#[cfg(feature = "apps")]
async fn forward_mcp_apps_reused_core(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
method: fastmcp_protocol::McpAppsRoutedMethod,
params: Option<serde_json::Value>,
) -> McpResult<serde_json::Value> {
if cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
if !self.mcp_apps_active() {
return Err(McpError::invalid_request(
"MCP Apps reused methods require the current bilateral activation receipt",
));
}
let (core_method, parameters) = match method {
fastmcp_protocol::McpAppsRoutedMethod::ToolsCall => (
"tools/call",
params.ok_or_else(|| {
McpError::invalid_params("Apps tools/call is missing parameters")
})?,
),
fastmcp_protocol::McpAppsRoutedMethod::ResourcesRead => (
"resources/read",
params.ok_or_else(|| {
McpError::invalid_params("Apps resources/read is missing parameters")
})?,
),
fastmcp_protocol::McpAppsRoutedMethod::ResourcesList => (
"resources/list",
params.unwrap_or_else(|| serde_json::json!({})),
),
fastmcp_protocol::McpAppsRoutedMethod::ResourceTemplatesList => (
"resources/templates/list",
params.unwrap_or_else(|| serde_json::json!({})),
),
fastmcp_protocol::McpAppsRoutedMethod::PromptsList => (
"prompts/list",
params.unwrap_or_else(|| serde_json::json!({})),
),
_ => {
return Err(McpError::invalid_params(
"Apps method is not a direction-correct standard-reused core request",
));
}
};
let result = self
.request_final_core_with_cancellation(cx, cancellation, core_method, parameters)
.await
.map_err(|error| McpError::invalid_request(error.to_string()))?;
mcp_apps::project_reused_core_result(method, result)
}
/// Returns the immutable policy and endpoints used to create this client.
#[must_use]
pub const fn protocol_plan(&self) -> &ClientProtocolPlan {
self.connection.protocol_plan()
}
/// Returns the identity advertised during connection setup.
#[must_use]
pub const fn client_info(&self) -> &ClientInfo {
&self.client_info
}
/// Returns the capabilities advertised during connection setup.
#[must_use]
pub const fn client_capabilities(&self) -> &ClientCapabilities {
&self.client_capabilities
}
/// Returns the server identity admitted by discovery or initialization.
#[must_use]
pub const fn server_info(&self) -> &ServerInfo {
&self.server_info
}
/// Returns server instructions retained from the successful handshake.
///
/// Modern HTTP prefers the final discovery string captured at connect.
/// Exact 2024-11-05 HTTP returns the initialize result field. A missing
/// value means the peer did not advertise instructions.
#[must_use]
pub fn instructions(&self) -> Option<&str> {
self.instructions.as_deref()
}
/// Returns exact legacy capabilities when the selected peer is legacy.
#[cfg(feature = "legacy-2024-11-05")]
#[must_use]
pub const fn legacy_server_capabilities(&self) -> Option<&ServerCapabilities> {
self.legacy_server_capabilities.as_ref()
}
/// Returns the exact modern discovery result when the selected peer is modern.
#[must_use]
pub fn server_discovery(&self) -> Option<ServerDiscoverResult> {
self.connection.server_discovery()
}
/// Returns the underlying policy-bound HTTP transport.
#[must_use]
pub const fn connection(&self) -> &ClientHttpConnection {
&self.connection
}
/// Returns mutable access to the underlying policy-bound HTTP transport.
pub fn connection_mut(&mut self) -> &mut ClientHttpConnection {
&mut self.connection
}
/// Pops one exact-2024 server notification received after this HTTP client
/// became ready. The ready legacy SSE receiver drains these independently
/// of ordinary client requests; modern HTTP has no shared legacy stream.
#[cfg(feature = "legacy-2024-11-05")]
#[must_use]
pub fn take_legacy_notification(&mut self) -> Option<JsonRpcRequest> {
self.connection.take_legacy_notification()
}
/// Drains non-progress final server notifications received on modern HTTP
/// request-owned SSE bodies.
///
/// Exact 2024-11-05 sessions never retain values here; use
/// [`Self::take_legacy_notification`] for the shared legacy SSE stream.
#[must_use]
pub fn take_final_server_notifications(&mut self) -> Vec<ServerNotification> {
self.final_server_notifications.drain(..).collect()
}
/// Drains exact final progress notifications received on modern HTTP
/// request-owned SSE bodies.
#[must_use]
pub fn take_final_progress_notifications(&mut self) -> Vec<FinalProgressNotificationParams> {
self.final_progress_notifications.drain(..).collect()
}
fn retain_final_http_notifications(
&mut self,
server: Vec<ServerNotification>,
progress: Vec<FinalProgressNotificationParams>,
) {
for notification in &server {
self.final_result_cache
.invalidate_notification(notification);
}
self.final_server_notifications.extend(server);
self.final_progress_notifications.extend(progress);
}
/// Consumes the high-level wrapper and returns its transport.
#[must_use]
pub fn into_connection(self) -> ClientHttpConnection {
self.connection
}
fn next_request_id(&self) -> Result<RequestId, HttpClientError> {
let id = self
.next_id
.try_update(Ordering::Relaxed, Ordering::Relaxed, |id| {
(id < i64::MAX as u64).then_some(id + 1)
})
.map_err(|_| HttpClientError::RequestIdExhausted)?;
Ok(RequestId::Number(id as i64))
}
fn next_mrtr_request_id(&self) -> McpResult<RequestId> {
self.next_request_id().map_err(|error| match error {
HttpClientError::RequestIdExhausted => {
McpError::internal_error("HTTP client request IDs are exhausted")
}
_ => McpError::internal_error("HTTP client could not allocate an MRTR request ID"),
})
}
fn require_terminal_http_mrtr_result(
&self,
method: &'static str,
result: CoreResult,
) -> Result<FinalCoreResult, HttpClientError> {
match (method, result) {
("tools/call", CoreResult::Final(result @ FinalCoreResult::ToolsCall { .. }))
| (
"resources/read",
CoreResult::Final(result @ FinalCoreResult::ResourcesRead { .. }),
)
| ("prompts/get", CoreResult::Final(result @ FinalCoreResult::PromptsGet { .. })) => {
Ok(result)
}
("tools/call", CoreResult::Final(FinalCoreResult::ToolsCallInputRequired { .. }))
| (
"resources/read",
CoreResult::Final(FinalCoreResult::ResourcesReadInputRequired { .. }),
)
| ("prompts/get", CoreResult::Final(FinalCoreResult::PromptsGetInputRequired { .. })) => {
Err(HttpClientError::CoreResult(McpError::invalid_request(
format!("ordinary HTTP MRTR {method} ended without a terminal result"),
)))
}
#[cfg(feature = "tasks")]
("tools/call", CoreResult::Final(FinalCoreResult::ToolsCallTask { .. })) => {
Err(HttpClientError::CoreResult(McpError::invalid_request(
format!("ordinary HTTP MRTR {method} ended without a terminal result"),
)))
}
(_, CoreResult::Legacy(_)) => Err(HttpClientError::CoreResult(
McpError::invalid_request("ordinary HTTP MRTR requires a modern final result"),
)),
_ => Err(HttpClientError::CoreResult(McpError::invalid_request(
format!("ordinary HTTP MRTR received an unexpected terminal result for {method}"),
))),
}
}
/// Returns whether typed final complete-result caching is enabled for this
/// HTTP client. The raw streaming [`Self::request`] API remains uncached.
#[must_use]
pub const fn final_result_cache_enabled(&self) -> bool {
self.final_result_cache.is_enabled()
}
/// Enables or disables typed final complete-result caching for this HTTP
/// client without discarding its local entries.
pub fn set_final_result_cache_enabled(&mut self, enabled: bool) {
self.final_result_cache.set_enabled(enabled);
}
/// Returns aggregate counters for the HTTP client's bounded final cache.
#[must_use]
pub const fn final_result_cache_stats(&self) -> FinalCacheStats {
self.final_result_cache.stats()
}
/// Removes all retained typed final complete results for this HTTP client.
pub fn clear_final_result_cache(&mut self) {
self.final_result_cache.clear();
}
/// Drains compatibility diagnostics for final peer TTLs admitted with zero
/// freshness by [`Self::request_final_core`].
#[must_use]
pub fn take_final_cache_ttl_diagnostics(&mut self) -> Vec<FinalCacheTtlDiagnostic> {
self.final_cache_ttl_diagnostics.drain(..).collect()
}
/// Sends one supported core request and returns its typed result. Cacheable
/// modern complete results use this HTTP client's bounded local cache; the
/// raw streaming [`Self::request`] surface deliberately remains unchanged.
pub async fn request_final_core(
&mut self,
cx: &Cx,
method: impl AsRef<str>,
parameters: serde_json::Value,
) -> Result<CoreResult, HttpClientError> {
self.request_final_core_with_optional_cancellation(cx, None, method.as_ref(), parameters)
.await
}
/// Sends one supported core request under a caller-owned cancellation
/// domain.
///
/// Unlike [`Self::request_final_core`], this path honors
/// [`McpRequestCancellation`] for the HTTP exchange itself, including the
/// wait for response headers and the disposable JSON body. Ordinary
/// `tools/call` and `tools/list` callers no longer need the Apps feature
/// to cancel a live HTTP request.
pub async fn request_final_core_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
method: impl AsRef<str>,
parameters: serde_json::Value,
) -> Result<CoreResult, HttpClientError> {
self.request_final_core_with_optional_cancellation(
cx,
Some(cancellation),
method.as_ref(),
parameters,
)
.await
}
async fn request_final_core_with_optional_cancellation(
&mut self,
cx: &Cx,
cancellation: Option<&McpRequestCancellation>,
method: &str,
parameters: serde_json::Value,
) -> Result<CoreResult, HttpClientError> {
if cx.checkpoint().is_err()
|| cancellation.is_some_and(McpRequestCancellation::is_cancel_requested)
{
return Err(HttpClientError::CoreResult(McpError::request_cancelled()));
}
let core_parameters = self.core_request_parameters(¶meters)?;
let core_request =
CoreRequest::decode(self.selected_protocol_era(), method, Some(&core_parameters))
.map_err(|_| {
HttpClientError::CoreResult(McpError::invalid_params(
"HTTP core request parameters do not match the negotiated protocol era",
))
})?;
let result_set = final_cache_result_set(&core_request);
let key = if self.selected_protocol_era() == ProtocolEra::Modern2026 {
result_set
.as_ref()
.map(|result_set| {
self.final_cache_key(method, parameters.clone(), result_set.clone())
})
.transpose()?
} else {
None
};
if let Some(key) = key.as_ref()
&& let FinalCacheLookup::Fresh(result) = self.final_result_cache.lookup(key)
{
if cx.checkpoint().is_err()
|| cancellation.is_some_and(McpRequestCancellation::is_cancel_requested)
{
return Err(HttpClientError::CoreResult(McpError::request_cancelled()));
}
return Ok(result);
}
let generation = key
.as_ref()
.map(|key| self.final_result_cache.begin_fetch(key.result_set()));
let request_id = self.next_request_id()?;
let response = match cancellation {
Some(cancellation) => {
self.connection
.request_json_with_result_source_at_with_cancellation(
cx,
cancellation,
method,
core_parameters,
request_id,
DEFAULT_FINAL_CACHE_MAX_BYTES,
)
.await
}
None => {
self.connection
.request_json_with_result_source_at(
cx,
method,
core_parameters,
request_id,
DEFAULT_FINAL_CACHE_MAX_BYTES,
)
.await
}
}
.map_err(HttpClientError::Connection)?;
let (mut response, result_source, receipt, server_notifications, progress_notifications) =
response;
self.retain_final_http_notifications(server_notifications, progress_notifications);
if let Some(error) = response.error.take() {
return Err(HttpClientError::CoreResult(json_rpc_error_to_mcp(error)));
}
let raw_result = response.result.take().ok_or_else(|| {
HttpClientError::CoreResult(McpError::invalid_request("HTTP response has no result"))
})?;
let (result, ttl_diagnostic) = decode_core_result_with_cache_ttl_from_source(
&core_request,
&raw_result,
result_source.as_deref(),
)
.map_err(HttpClientError::CoreResult)?;
if let Some(diagnostic) = ttl_diagnostic {
if self.final_cache_ttl_diagnostics.len() >= MAX_FINAL_CACHE_TTL_DIAGNOSTICS {
self.final_cache_ttl_diagnostics.pop_front();
}
self.final_cache_ttl_diagnostics.push_back(diagnostic);
}
if let (Some(key), Some(generation)) = (key, generation) {
let _ = self.final_result_cache.insert_if_current_at(
key,
generation,
result.clone(),
receipt,
);
}
Ok(result)
}
/// Completes one prompt or resource-template argument in the selected era.
///
/// Modern HTTP retains the complete [`CompletionParams`] shape. Exact
/// MCP 2024-11-05 first projects only the lossless title-free,
/// context-free subset, before this method can allocate a request ID or
/// open the configured legacy message POST. Auto follows the immutable
/// era selected during connection setup.
pub async fn complete(
&mut self,
cx: &Cx,
params: CompletionParams,
) -> Result<CoreResult, HttpClientError> {
let parameters = match self.selected_protocol_era() {
ProtocolEra::Modern2026 => serde_json::to_value(params).map_err(|_| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP modern completion parameters could not serialize",
))
})?,
ProtocolEra::Legacy2024 => {
serde_json::to_value(params.into_legacy()?).map_err(|_| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP legacy completion parameters could not serialize",
))
})?
}
};
self.request_final_core(cx, "completion/complete", parameters)
.await
}
/// Completes one prompt or resource-template argument under a caller-owned
/// cancellation domain.
pub async fn complete_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
params: CompletionParams,
) -> Result<CoreResult, HttpClientError> {
let parameters = match self.selected_protocol_era() {
ProtocolEra::Modern2026 => serde_json::to_value(params).map_err(|_| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP modern completion parameters could not serialize",
))
})?,
ProtocolEra::Legacy2024 => {
serde_json::to_value(params.into_legacy()?).map_err(|_| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP legacy completion parameters could not serialize",
))
})?
}
};
self.request_final_core_with_cancellation(
cx,
cancellation,
"completion/complete",
parameters,
)
.await
}
/// Completes one prompt or resource-template argument and admits
/// request-scoped `notifications/progress` for the supplied marker.
pub async fn complete_with_progress_marker(
&mut self,
cx: &Cx,
params: CompletionParams,
progress_marker: ProgressMarker,
) -> Result<CoreResult, HttpClientError> {
let token = serde_json::to_value(progress_marker).map_err(|_| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP progress token could not be encoded",
))
})?;
let mut parameters = match self.selected_protocol_era() {
ProtocolEra::Modern2026 => serde_json::to_value(params).map_err(|_| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP modern completion parameters could not serialize",
))
})?,
ProtocolEra::Legacy2024 => {
serde_json::to_value(params.into_legacy()?).map_err(|_| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP legacy completion parameters could not serialize",
))
})?
}
};
let object = parameters.as_object_mut().ok_or_else(|| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP completion parameters must remain an object",
))
})?;
object.insert(
"_meta".to_owned(),
serde_json::json!({ "progressToken": token }),
);
self.request_final_core(cx, "completion/complete", parameters)
.await
}
fn http_list_parameters(cursor: Option<&str>) -> serde_json::Value {
list_catalog_wire_parameters(cursor, None, None)
}
/// Lists one page of tools through the negotiated HTTP era.
pub async fn list_tools(
&mut self,
cx: &Cx,
cursor: Option<&str>,
) -> Result<CoreResult, HttpClientError> {
self.list_tools_with_params(
cx,
ListToolsParams {
cursor: cursor.map(ToOwned::to_owned),
..ListToolsParams::default()
},
)
.await
}
/// Lists one page of tools with include/exclude tag filters.
pub async fn list_tools_with_params(
&mut self,
cx: &Cx,
params: ListToolsParams,
) -> Result<CoreResult, HttpClientError> {
self.request_final_core(
cx,
"tools/list",
list_catalog_wire_parameters(
params.cursor.as_deref(),
params.include_tags.as_ref(),
params.exclude_tags.as_ref(),
),
)
.await
}
/// Sends `ping` through the negotiated HTTP era.
pub async fn ping(&mut self, cx: &Cx) -> Result<(), HttpClientError> {
self.ping_with_optional_cancellation(cx, None).await
}
/// Sends `ping` under a caller-owned cancellation domain.
pub async fn ping_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
) -> Result<(), HttpClientError> {
self.ping_with_optional_cancellation(cx, Some(cancellation))
.await
}
async fn ping_with_optional_cancellation(
&mut self,
cx: &Cx,
cancellation: Option<&McpRequestCancellation>,
) -> Result<(), HttpClientError> {
if cx.checkpoint().is_err()
|| cancellation.is_some_and(McpRequestCancellation::is_cancel_requested)
{
return Err(HttpClientError::CoreResult(McpError::request_cancelled()));
}
let parameters = self.core_request_parameters(&serde_json::json!({}))?;
let request_id = self.next_request_id()?;
let response = match cancellation {
Some(cancellation) => {
self.connection
.request_json_with_result_source_at_with_cancellation(
cx,
cancellation,
"ping",
parameters,
request_id,
DEFAULT_FINAL_CACHE_MAX_BYTES,
)
.await
}
None => {
self.connection
.request_json_with_result_source_at(
cx,
"ping",
parameters,
request_id,
DEFAULT_FINAL_CACHE_MAX_BYTES,
)
.await
}
}
.map_err(HttpClientError::Connection)?;
let (mut response, _, _, server_notifications, progress_notifications) = response;
self.retain_final_http_notifications(server_notifications, progress_notifications);
if let Some(error) = response.error.take() {
return Err(HttpClientError::CoreResult(json_rpc_error_to_mcp(error)));
}
Ok(())
}
/// Configures the selected protocol era's log level behavior.
///
/// A modern session stores the complete RFC 5424 level and adds it as
/// `io.modelcontextprotocol/logLevel` metadata to every later request. It
/// never sends `logging/setLevel`. An exact 2024-11-05 session must use
/// the legacy `logging/setLevel` verb instead.
pub fn set_log_level_typed(&mut self, level: LoggingLevel) -> Result<(), HttpClientError> {
match self.selected_protocol_era() {
ProtocolEra::Modern2026 => {
self.final_log_level = Some(level);
Ok(())
}
ProtocolEra::Legacy2024 => Err(HttpClientError::CoreResult(McpError::invalid_params(
"modern request logLevel metadata is only for MCP 2026-07-28",
))),
}
}
/// Configures one of the RFC 5424 severities supported by both protocol eras.
pub fn set_log_level(&mut self, level: LogLevel) -> Result<(), HttpClientError> {
self.set_log_level_typed(final_log_level(level))
}
/// Returns the modern request `logLevel` stored by [`Self::set_log_level_typed`].
#[must_use]
pub fn log_level(&self) -> Option<LoggingLevel> {
self.final_log_level
}
/// Lists one page of tools under a caller-owned cancellation domain.
pub async fn list_tools_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
cursor: Option<&str>,
) -> Result<CoreResult, HttpClientError> {
self.list_tools_with_params_and_cancellation(
cx,
cancellation,
ListToolsParams {
cursor: cursor.map(ToOwned::to_owned),
..ListToolsParams::default()
},
)
.await
}
/// Lists one tag-filtered tools page under a caller-owned cancellation domain.
pub async fn list_tools_with_params_and_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
params: ListToolsParams,
) -> Result<CoreResult, HttpClientError> {
self.request_final_core_with_cancellation(
cx,
cancellation,
"tools/list",
list_catalog_wire_parameters(
params.cursor.as_deref(),
params.include_tags.as_ref(),
params.exclude_tags.as_ref(),
),
)
.await
}
/// Lists one page of resources under a caller-owned cancellation domain.
pub async fn list_resources_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
cursor: Option<&str>,
) -> Result<CoreResult, HttpClientError> {
self.list_resources_with_params_and_cancellation(
cx,
cancellation,
ListResourcesParams {
cursor: cursor.map(ToOwned::to_owned),
..ListResourcesParams::default()
},
)
.await
}
/// Lists one tag-filtered resources page under a caller-owned cancellation domain.
pub async fn list_resources_with_params_and_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
params: ListResourcesParams,
) -> Result<CoreResult, HttpClientError> {
self.request_final_core_with_cancellation(
cx,
cancellation,
"resources/list",
list_catalog_wire_parameters(
params.cursor.as_deref(),
params.include_tags.as_ref(),
params.exclude_tags.as_ref(),
),
)
.await
}
/// Lists one page of resource templates under a caller-owned cancellation
/// domain.
pub async fn list_resource_templates_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
cursor: Option<&str>,
) -> Result<CoreResult, HttpClientError> {
self.list_resource_templates_with_params_and_cancellation(
cx,
cancellation,
ListResourceTemplatesParams {
cursor: cursor.map(ToOwned::to_owned),
..ListResourceTemplatesParams::default()
},
)
.await
}
/// Lists one tag-filtered templates page under a caller-owned cancellation domain.
pub async fn list_resource_templates_with_params_and_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
params: ListResourceTemplatesParams,
) -> Result<CoreResult, HttpClientError> {
self.request_final_core_with_cancellation(
cx,
cancellation,
"resources/templates/list",
list_catalog_wire_parameters(
params.cursor.as_deref(),
params.include_tags.as_ref(),
params.exclude_tags.as_ref(),
),
)
.await
}
/// Lists one page of prompts under a caller-owned cancellation domain.
pub async fn list_prompts_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
cursor: Option<&str>,
) -> Result<CoreResult, HttpClientError> {
self.list_prompts_with_params_and_cancellation(
cx,
cancellation,
ListPromptsParams {
cursor: cursor.map(ToOwned::to_owned),
..ListPromptsParams::default()
},
)
.await
}
/// Lists one tag-filtered prompts page under a caller-owned cancellation domain.
pub async fn list_prompts_with_params_and_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
params: ListPromptsParams,
) -> Result<CoreResult, HttpClientError> {
self.request_final_core_with_cancellation(
cx,
cancellation,
"prompts/list",
list_catalog_wire_parameters(
params.cursor.as_deref(),
params.include_tags.as_ref(),
params.exclude_tags.as_ref(),
),
)
.await
}
/// Lists one page of resources through the negotiated HTTP era.
pub async fn list_resources(
&mut self,
cx: &Cx,
cursor: Option<&str>,
) -> Result<CoreResult, HttpClientError> {
self.list_resources_with_params(
cx,
ListResourcesParams {
cursor: cursor.map(ToOwned::to_owned),
..ListResourcesParams::default()
},
)
.await
}
/// Lists one page of resources with include/exclude tag filters.
pub async fn list_resources_with_params(
&mut self,
cx: &Cx,
params: ListResourcesParams,
) -> Result<CoreResult, HttpClientError> {
self.request_final_core(
cx,
"resources/list",
list_catalog_wire_parameters(
params.cursor.as_deref(),
params.include_tags.as_ref(),
params.exclude_tags.as_ref(),
),
)
.await
}
/// Lists one page of resource templates through the negotiated HTTP era.
pub async fn list_resource_templates(
&mut self,
cx: &Cx,
cursor: Option<&str>,
) -> Result<CoreResult, HttpClientError> {
self.list_resource_templates_with_params(
cx,
ListResourceTemplatesParams {
cursor: cursor.map(ToOwned::to_owned),
..ListResourceTemplatesParams::default()
},
)
.await
}
/// Lists one page of resource templates with include/exclude tag filters.
pub async fn list_resource_templates_with_params(
&mut self,
cx: &Cx,
params: ListResourceTemplatesParams,
) -> Result<CoreResult, HttpClientError> {
self.request_final_core(
cx,
"resources/templates/list",
list_catalog_wire_parameters(
params.cursor.as_deref(),
params.include_tags.as_ref(),
params.exclude_tags.as_ref(),
),
)
.await
}
/// Lists one page of prompts through the negotiated HTTP era.
pub async fn list_prompts(
&mut self,
cx: &Cx,
cursor: Option<&str>,
) -> Result<CoreResult, HttpClientError> {
self.list_prompts_with_params(
cx,
ListPromptsParams {
cursor: cursor.map(ToOwned::to_owned),
..ListPromptsParams::default()
},
)
.await
}
/// Lists one page of prompts with include/exclude tag filters.
pub async fn list_prompts_with_params(
&mut self,
cx: &Cx,
params: ListPromptsParams,
) -> Result<CoreResult, HttpClientError> {
self.request_final_core(
cx,
"prompts/list",
list_catalog_wire_parameters(
params.cursor.as_deref(),
params.include_tags.as_ref(),
params.exclude_tags.as_ref(),
),
)
.await
}
fn http_prompt_parameters(
name: &str,
arguments: std::collections::HashMap<String, String>,
) -> Result<serde_json::Value, HttpClientError> {
let mut parameters = serde_json::json!({ "name": name });
if !arguments.is_empty() {
let object = parameters.as_object_mut().ok_or_else(|| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP prompt parameters must remain an object",
))
})?;
object.insert(
"arguments".to_owned(),
serde_json::to_value(arguments).map_err(|_| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP prompt arguments could not serialize",
))
})?,
);
}
Ok(parameters)
}
/// Reads one resource through the negotiated HTTP era.
///
/// Installed modern reverse handlers fulfill `input_required` the same way
/// as [`Self::call_tool`].
pub async fn read_resource(
&mut self,
cx: &Cx,
uri: &str,
) -> Result<CoreResult, HttpClientError> {
self.follow_installed_mrtr(
cx,
None,
"resources/read",
serde_json::json!({ "uri": uri }),
)
.await
}
/// Reads one resource under a caller-owned cancellation domain.
///
/// Installed modern reverse handlers fulfill `input_required` the same way
/// as [`Self::call_tool`]. Cancellation is checked on every HTTP round.
pub async fn read_resource_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
uri: &str,
) -> Result<CoreResult, HttpClientError> {
self.follow_installed_mrtr(
cx,
Some(cancellation),
"resources/read",
serde_json::json!({ "uri": uri }),
)
.await
}
/// Reads one resource and admits request-scoped `notifications/progress`
/// for the supplied progress marker.
///
/// Drain those frames with [`Self::take_final_progress_notifications`].
pub async fn read_resource_with_progress_marker(
&mut self,
cx: &Cx,
uri: &str,
progress_marker: ProgressMarker,
) -> Result<CoreResult, HttpClientError> {
let token = serde_json::to_value(progress_marker).map_err(|_| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP progress token could not be encoded",
))
})?;
self.follow_installed_mrtr(
cx,
None,
"resources/read",
serde_json::json!({
"uri": uri,
"_meta": { "progressToken": token },
}),
)
.await
}
/// Gets one prompt through the negotiated HTTP era.
///
/// Installed modern reverse handlers fulfill `input_required` the same way
/// as [`Self::call_tool`].
pub async fn get_prompt(
&mut self,
cx: &Cx,
name: &str,
arguments: std::collections::HashMap<String, String>,
) -> Result<CoreResult, HttpClientError> {
self.follow_installed_mrtr(
cx,
None,
"prompts/get",
Self::http_prompt_parameters(name, arguments)?,
)
.await
}
/// Gets one prompt under a caller-owned cancellation domain.
///
/// Installed modern reverse handlers fulfill `input_required` the same way
/// as [`Self::call_tool`]. Cancellation is checked on every HTTP round.
pub async fn get_prompt_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
name: &str,
arguments: std::collections::HashMap<String, String>,
) -> Result<CoreResult, HttpClientError> {
self.follow_installed_mrtr(
cx,
Some(cancellation),
"prompts/get",
Self::http_prompt_parameters(name, arguments)?,
)
.await
}
/// Gets one prompt and admits request-scoped `notifications/progress` for
/// the supplied progress marker.
///
/// Drain those frames with [`Self::take_final_progress_notifications`].
pub async fn get_prompt_with_progress_marker(
&mut self,
cx: &Cx,
name: &str,
arguments: std::collections::HashMap<String, String>,
progress_marker: ProgressMarker,
) -> Result<CoreResult, HttpClientError> {
let token = serde_json::to_value(progress_marker).map_err(|_| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP progress token could not be encoded",
))
})?;
let mut parameters = Self::http_prompt_parameters(name, arguments)?;
let object = parameters.as_object_mut().ok_or_else(|| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP prompt parameters must remain an object",
))
})?;
object.insert(
"_meta".to_owned(),
serde_json::json!({ "progressToken": token }),
);
self.follow_installed_mrtr(cx, None, "prompts/get", parameters)
.await
}
/// Calls one tool through the negotiated HTTP era.
///
/// When modern reverse handlers are installed, a peer `input_required`
/// result is fulfilled locally and retried with `inputResponses` instead of
/// being returned to the caller.
pub async fn call_tool(
&mut self,
cx: &Cx,
name: &str,
arguments: serde_json::Value,
) -> Result<CoreResult, HttpClientError> {
self.follow_installed_mrtr(
cx,
None,
"tools/call",
serde_json::json!({ "name": name, "arguments": arguments }),
)
.await
}
/// Calls one tool under a caller-owned cancellation domain.
///
/// Installed modern reverse handlers fulfill `input_required` the same way
/// as [`Self::call_tool`]. Cancellation is checked on every HTTP round.
pub async fn call_tool_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
name: &str,
arguments: serde_json::Value,
) -> Result<CoreResult, HttpClientError> {
self.follow_installed_mrtr(
cx,
Some(cancellation),
"tools/call",
serde_json::json!({ "name": name, "arguments": arguments }),
)
.await
}
/// Calls one tool and admits request-scoped `notifications/progress` for
/// the supplied progress marker.
///
/// Drain those frames with [`Self::take_final_progress_notifications`].
pub async fn call_tool_with_progress_marker(
&mut self,
cx: &Cx,
name: &str,
arguments: serde_json::Value,
progress_marker: ProgressMarker,
) -> Result<CoreResult, HttpClientError> {
let token = serde_json::to_value(progress_marker).map_err(|_| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP progress token could not be encoded",
))
})?;
self.follow_installed_mrtr(
cx,
None,
"tools/call",
serde_json::json!({
"name": name,
"arguments": arguments,
"_meta": { "progressToken": token },
}),
)
.await
}
async fn follow_installed_mrtr(
&mut self,
cx: &Cx,
cancellation: Option<&McpRequestCancellation>,
method: &str,
original_parameters: serde_json::Value,
) -> Result<CoreResult, HttpClientError> {
let handlers = self
.connection
.modern_reverse_request_handlers()
.filter(|handlers| handlers.has_modern_handlers())
.cloned();
let mut parameters = original_parameters.clone();
let mut rounds = 0_usize;
loop {
let result = match cancellation {
Some(cancellation) => {
self.request_final_core_with_cancellation(cx, cancellation, method, parameters)
.await?
}
None => self.request_final_core(cx, method, parameters).await?,
};
let Some(input_required) = mrtr_input_required_for_method(method, &result) else {
return Ok(result);
};
let Some(handlers) = handlers.as_ref() else {
return Ok(result);
};
if rounds >= MAX_MRTR_CONTINUATION_ROUNDS {
return Err(HttpClientError::CoreResult(McpError::invalid_request(
"MRTR continuation-round limit exceeded",
)));
}
rounds += 1;
let input_responses = handlers
.respond_to_input_required_async(cx, input_required)
.await?;
parameters = mrtr_retry_parameters(
original_parameters.clone(),
input_required,
input_responses,
)?;
}
}
/// Sends one raw final extension request after exact bilateral admission.
///
/// The configured frozen registry must own `extension_id`, and the
/// retained final discovery exchange must admit `method` as a
/// client-to-server request. Those checks happen before this HTTP client
/// allocates an ID or starts a POST. The successful result is decoded from
/// the existing source-preserving correlated JSON-RPC path.
pub async fn request_final_extension(
&mut self,
cx: &Cx,
extension_id: &fastmcp_protocol::ExtensionId,
method: &str,
parameters: serde_json::Value,
) -> Result<serde_json::Value, HttpClientError> {
// This preflight fences request-ID allocation for negative admission.
// The HTTP execution seam repeats the same immutable registry check
// so no ordinary core/raw request path can select its extension lane.
self.connection
.admit_final_extension_method(extension_id, method)
.map_err(HttpClientError::CoreResult)?;
if cx.checkpoint().is_err() {
return Err(HttpClientError::CoreResult(McpError::request_cancelled()));
}
let request_id = self.next_request_id()?;
let (mut response, result_source, _, server_notifications, progress_notifications) = self
.connection
.request_final_extension_json_with_result_source_at(
cx,
extension_id,
method,
parameters,
request_id,
DEFAULT_FINAL_CACHE_MAX_BYTES,
)
.await
.map_err(HttpClientError::Connection)?;
self.retain_final_http_notifications(server_notifications, progress_notifications);
if let Some(error) = response.error.take() {
return Err(HttpClientError::CoreResult(json_rpc_error_to_mcp(error)));
}
let result_source = result_source.ok_or_else(|| {
HttpClientError::CoreResult(McpError::invalid_request(
"HTTP final extension response has no admitted result source",
))
})?;
serde_json::from_str(&result_source).map_err(|_| {
HttpClientError::CoreResult(McpError::invalid_request(
"HTTP final extension response result is not valid JSON",
))
})
}
/// Calls a tool through ordinary modern HTTP, following bounded MRTR
/// continuations until one terminal tool result arrives.
///
/// The supplied absolute `deadline` owns the entire operation, including
/// the initial request and every continuation. This client allocates the
/// initial and continuation IDs from its monotonic allocator; `respond`
/// can therefore be invoked once for each admitted `input_required`
/// result. Tasks are neither requested nor accepted by this operation.
pub async fn call_tool_with_mrtr_retry<F>(
&mut self,
cx: &Cx,
deadline: Instant,
name: &str,
arguments: serde_json::Value,
sse_limits: sse::SseLimits,
maximum_response_bytes: usize,
respond: F,
) -> Result<FinalCoreResult, HttpClientError>
where
F: FnMut(&InputRequiredResult) -> McpResult<MrtrInputResponses>,
{
let initial_request_id = self.next_request_id()?;
let result = self
.connection
.call_tool_with_mrtr_retry(
cx,
initial_request_id,
deadline,
name,
arguments,
sse_limits,
maximum_response_bytes,
|| self.next_mrtr_request_id(),
respond,
)
.await
.map_err(HttpClientError::Connection)?;
self.require_terminal_http_mrtr_result("tools/call", result)
}
/// Reads a resource through ordinary modern HTTP, following bounded MRTR
/// continuations until one terminal resource result arrives.
///
/// See [`Self::call_tool_with_mrtr_retry`] for deadline, request-ID,
/// callback, and Tasks behavior.
pub async fn read_resource_with_mrtr_retry<F>(
&mut self,
cx: &Cx,
deadline: Instant,
uri: &str,
sse_limits: sse::SseLimits,
maximum_response_bytes: usize,
respond: F,
) -> Result<FinalCoreResult, HttpClientError>
where
F: FnMut(&InputRequiredResult) -> McpResult<MrtrInputResponses>,
{
let initial_request_id = self.next_request_id()?;
let result = self
.connection
.read_resource_with_mrtr_retry(
cx,
initial_request_id,
deadline,
uri,
sse_limits,
maximum_response_bytes,
|| self.next_mrtr_request_id(),
respond,
)
.await
.map_err(HttpClientError::Connection)?;
self.require_terminal_http_mrtr_result("resources/read", result)
}
/// Gets a prompt through ordinary modern HTTP, following bounded MRTR
/// continuations until one terminal prompt result arrives.
///
/// See [`Self::call_tool_with_mrtr_retry`] for deadline, request-ID,
/// callback, and Tasks behavior.
pub async fn get_prompt_with_mrtr_retry<F>(
&mut self,
cx: &Cx,
deadline: Instant,
name: &str,
arguments: std::collections::HashMap<String, String>,
sse_limits: sse::SseLimits,
maximum_response_bytes: usize,
respond: F,
) -> Result<FinalCoreResult, HttpClientError>
where
F: FnMut(&InputRequiredResult) -> McpResult<MrtrInputResponses>,
{
let initial_request_id = self.next_request_id()?;
let result = self
.connection
.get_prompt_with_mrtr_retry(
cx,
initial_request_id,
deadline,
name,
arguments,
sse_limits,
maximum_response_bytes,
|| self.next_mrtr_request_id(),
respond,
)
.await
.map_err(HttpClientError::Connection)?;
self.require_terminal_http_mrtr_result("prompts/get", result)
}
/// Opens a live final HTTP subscription listener.
///
/// Accepted catalog and resource events invalidate their result sets before
/// the listener yields them. Progress, log, and Tasks notifications remain
/// cache-neutral.
pub async fn open_subscriptions_listener(
&mut self,
cx: &Cx,
notifications: SubscriptionFilter,
limits: sse::SseLimits,
) -> Result<HttpSubscriptionListener<'_>, HttpClientError> {
self.refuse_http_catalog_task_ids(¬ifications)?;
if cx.checkpoint().is_err() {
return Err(HttpClientError::CoreResult(McpError::request_cancelled()));
}
let request_id = self.next_request_id()?;
let listener = self
.connection
.open_subscriptions_listener(cx, request_id, notifications, limits)
.await
.map_err(HttpClientError::Connection)?;
Ok(HttpSubscriptionListener {
listener,
final_result_cache: &mut self.final_result_cache,
})
}
/// Starts an incremental HTTP catalog listener on this client.
///
/// Unlike [`Self::open_subscriptions_listener`], this does not borrow the
/// final-result cache for the listener lifetime. Call
/// [`Self::next_http_subscription_event`] so the same client can keep
/// issuing ordinary requests such as `tools/list` while the stream is live.
pub async fn start_subscriptions_listener(
&mut self,
cx: &Cx,
notifications: SubscriptionFilter,
limits: sse::SseLimits,
) -> Result<(), HttpClientError> {
self.refuse_http_catalog_task_ids(¬ifications)?;
if self.live_subscription.is_some() {
return Err(HttpClientError::CoreResult(McpError::invalid_request(
"A final HTTP subscription is already active on this client",
)));
}
if cx.checkpoint().is_err() {
return Err(HttpClientError::CoreResult(McpError::request_cancelled()));
}
let request_id = self.next_request_id()?;
let listener = self
.connection
.open_subscriptions_listener(cx, request_id, notifications, limits)
.await
.map_err(HttpClientError::Connection)?;
self.live_subscription = Some(listener);
Ok(())
}
/// Drives one incremental HTTP catalog listener event.
///
/// Accepted catalog and resource notifications invalidate cached result
/// sets before this method returns them. Terminal completion or a stream
/// error retires the live listener.
pub async fn next_http_subscription_event(
&mut self,
cx: &Cx,
) -> Result<Option<ModernHttpSubscriptionListenEvent>, HttpClientError> {
let event = {
let listener = self.live_subscription.as_mut().ok_or_else(|| {
HttpClientError::CoreResult(McpError::invalid_request(
"No live final HTTP subscription is active",
))
})?;
listener.next_event(cx).await
};
let event = match event {
Ok(event) => event,
Err(error) => {
self.live_subscription = None;
return Err(HttpClientError::Connection(
ClientHttpConnectionError::SubscriptionsListen(error),
));
}
};
if let Some(ModernHttpSubscriptionListenEvent::Notification(notification)) = event.as_ref()
{
self.final_result_cache
.invalidate_notification(notification);
}
if matches!(
event,
None | Some(ModernHttpSubscriptionListenEvent::Terminal { .. })
) {
self.live_subscription = None;
}
Ok(event)
}
// The Result is load-bearing under `--features tasks`, where the filter
// parse and taskIds refusal can fail; clippy only sees the default set.
#[allow(clippy::unnecessary_wraps)]
fn refuse_http_catalog_task_ids(
&self,
notifications: &SubscriptionFilter,
) -> Result<(), HttpClientError> {
#[cfg(feature = "tasks")]
{
let tasks_requested = task_subscription_ids(notifications).map_err(|_| {
HttpClientError::CoreResult(McpError::invalid_params(
"invalid Tasks subscription filter",
))
})?;
if tasks_requested.is_some() {
return Err(HttpClientError::CoreResult(McpError::invalid_params(
"A live catalog subscription cannot include taskIds; use open_final_task_subscription_listener",
)));
}
}
#[cfg(not(feature = "tasks"))]
let _ = notifications;
Ok(())
}
/// Starts an incremental official Tasks listener on this HTTP client.
///
/// Catalog [`Self::start_subscriptions_listener`] refuses `taskIds`. Call
/// [`Self::next_final_task_subscription_event`] so the same client can
/// keep issuing `tasks/get` / `tasks/cancel` while draining status
/// updates. HTTP catalog listen and Tasks listen use separate SSE POSTs,
/// so both may be live.
#[cfg(feature = "tasks")]
pub async fn open_final_task_subscription_listener(
&mut self,
cx: &Cx,
notifications: SubscriptionFilter,
limits: sse::SseLimits,
) -> Result<(), HttpClientError> {
if self.live_task_subscription.is_some() {
return Err(HttpClientError::CoreResult(McpError::invalid_request(
"A final HTTP Tasks subscription is already active on this client",
)));
}
if cx.checkpoint().is_err() {
return Err(HttpClientError::CoreResult(McpError::request_cancelled()));
}
let tasks_requested = task_subscription_ids(¬ifications).map_err(|_| {
HttpClientError::CoreResult(McpError::invalid_params(
"invalid Tasks subscription filter",
))
})?;
if tasks_requested.is_none() {
return Err(HttpClientError::CoreResult(McpError::invalid_params(
"A live final Tasks subscription requires taskIds",
)));
}
let request_id = self.next_request_id()?;
let listener = self
.connection
.open_subscriptions_listener(cx, request_id, notifications, limits)
.await
.map_err(HttpClientError::Connection)?;
self.live_task_subscription = Some(listener);
Ok(())
}
/// Drives one incremental official Tasks listener event.
#[cfg(feature = "tasks")]
pub async fn next_final_task_subscription_event(
&mut self,
cx: &Cx,
) -> Result<StdioTaskSubscriptionEvent, HttpClientError> {
loop {
let event = {
let listener = self.live_task_subscription.as_mut().ok_or_else(|| {
HttpClientError::CoreResult(McpError::invalid_request(
"No live final HTTP Tasks subscription is active",
))
})?;
listener.next_event(cx).await
};
let event = match event {
Ok(event) => event,
Err(error) => {
self.live_task_subscription = None;
return Err(HttpClientError::Connection(
ClientHttpConnectionError::SubscriptionsListen(error),
));
}
};
match event {
Some(ModernHttpSubscriptionListenEvent::Acknowledged { accepted_filter }) => {
return Ok(StdioTaskSubscriptionEvent::Acknowledged(accepted_filter));
}
Some(ModernHttpSubscriptionListenEvent::TaskNotification(notification)) => {
return Ok(StdioTaskSubscriptionEvent::Notification(notification));
}
Some(ModernHttpSubscriptionListenEvent::Notification(notification)) => {
self.final_result_cache
.invalidate_notification(¬ification);
}
Some(ModernHttpSubscriptionListenEvent::Terminal { .. }) | None => {
self.live_task_subscription = None;
return Ok(StdioTaskSubscriptionEvent::Terminal);
}
}
}
}
/// Collects one typed final HTTP subscription stream.
pub async fn listen_subscriptions_typed(
&mut self,
cx: &Cx,
notifications: SubscriptionFilter,
limits: sse::SseLimits,
) -> Result<ModernHttpSubscriptionListenCollector, HttpClientError> {
self.open_subscriptions_listener(cx, notifications, limits)
.await?
.collect(cx)
.await
}
/// Attaches a durable final Tasks handle by reading its current snapshot.
///
/// The returned handle remains transport-neutral state; later operations
/// take this same client so all requests retain its negotiated HTTP
/// connection and monotonic JSON-RPC request-ID allocation.
#[cfg(feature = "tasks")]
pub async fn attach_final_task(
&mut self,
cx: &Cx,
task_id: FinalTaskId,
) -> Result<FinalTaskHandle, HttpClientError> {
self.get_final_task_snapshot(cx, task_id)
.await
.map(FinalTaskHandle::new)
}
/// Requests cancellation for one exact final Tasks identifier.
///
/// The request ID is allocated from this client's private monotonic
/// allocator. The acknowledgement is intentionally returned without
/// projecting a task snapshot; callers that own a [`FinalTaskHandle`]
/// retain its current snapshot until a later poll or task notification.
#[cfg(feature = "tasks")]
pub async fn cancel_final_task(
&mut self,
cx: &Cx,
task_id: FinalTaskId,
) -> Result<FinalCancelTaskResult, HttpClientError> {
if cx.checkpoint().is_err() {
return Err(HttpClientError::CoreResult(McpError::request_cancelled()));
}
let request_id = self.next_request_id()?;
self.connection
.cancel_task_final(cx, request_id, task_id, DEFAULT_FINAL_CACHE_MAX_BYTES)
.await
.map_err(HttpClientError::Connection)
}
#[cfg(feature = "tasks")]
async fn get_final_task_snapshot(
&mut self,
cx: &Cx,
task_id: FinalTaskId,
) -> Result<FinalTask, HttpClientError> {
if cx.checkpoint().is_err() {
return Err(HttpClientError::CoreResult(McpError::request_cancelled()));
}
let request_id = self.next_request_id()?;
self.connection
.get_task_final(cx, request_id, task_id, DEFAULT_FINAL_CACHE_MAX_BYTES)
.await
.map(|result| result.task)
.map_err(HttpClientError::Connection)
}
#[cfg(feature = "tasks")]
async fn submit_final_task_input(
&mut self,
cx: &Cx,
task: &FinalTask,
input_responses: FinalTaskInputResponses,
) -> Result<FinalUpdateTaskResult, HttpClientError> {
if cx.checkpoint().is_err() {
return Err(HttpClientError::CoreResult(McpError::request_cancelled()));
}
let FinalTask::InputRequired { input_requests, .. } = task else {
return Err(HttpClientError::CoreResult(McpError::invalid_params(
"tasks/update requires an input_required final task",
)));
};
let ledger = TaskInputLedger::from_requests(input_requests).map_err(|_| {
HttpClientError::CoreResult(McpError::invalid_params(
"final task input requests are not an admitted ledger",
))
})?;
ledger.validate_responses(&input_responses).map_err(|_| {
HttpClientError::CoreResult(McpError::invalid_params(
"tasks/update inputResponses do not match the retained task input requests",
))
})?;
let request_id = self.next_request_id()?;
self.connection
.update_task_final(
cx,
request_id,
task,
input_responses,
DEFAULT_FINAL_CACHE_MAX_BYTES,
)
.await
.map_err(HttpClientError::Connection)
}
fn core_request_parameters(
&self,
parameters: &serde_json::Value,
) -> Result<serde_json::Value, HttpClientError> {
if self.selected_protocol_era() != ProtocolEra::Modern2026 {
return Ok(parameters.clone());
}
let mut parameters = parameters.as_object().cloned().ok_or_else(|| {
HttpClientError::CoreResult(McpError::invalid_params(
"HTTP modern core request parameters must be an object",
))
})?;
let mut metadata = serde_json::to_value(FinalRequestMeta::new(
self.client_capabilities.clone(),
))
.map_err(|_| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP client metadata could not form a core request",
))
})?;
if let Some(object) = metadata.as_object_mut() {
insert_final_request_log_level(object, self.final_log_level)
.map_err(HttpClientError::CoreResult)?;
if let Some(caller) = parameters
.get("_meta")
.and_then(serde_json::Value::as_object)
{
for (key, value) in caller {
object.entry(key.clone()).or_insert_with(|| value.clone());
}
}
}
parameters.insert("_meta".to_owned(), metadata);
Ok(serde_json::Value::Object(parameters))
}
fn final_cache_key(
&self,
method: &str,
semantic_parameters: serde_json::Value,
result_set: FinalCacheResultSet,
) -> Result<FinalCacheKey, HttpClientError> {
let normalized_capabilities =
serde_json::to_string(&self.client_capabilities).map_err(|_| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP client capabilities could not form a cache key",
))
})?;
let extension_settings = serde_json::to_string(&serde_json::json!({
"mcpApps": self.mcp_apps_settings.as_ref().map(|settings| {
settings.to_extension_settings().into_value()
}),
"descriptorRevision": FINAL_CACHE_EXTENSION_REVISION,
}))
.map_err(|_| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP client extension settings could not form a cache key",
))
})?;
let semantic_projection = serde_json::to_string(&semantic_parameters).map_err(|_| {
HttpClientError::CoreResult(McpError::internal_error(
"HTTP client semantic parameters could not form a cache key",
))
})?;
Ok(FinalCacheKey::new(
self.connection
.protocol_plan()
.modern_post_target()
.unwrap_or("http"),
MODERN_PROTOCOL_VERSION,
normalized_capabilities,
extension_settings,
method,
semantic_projection,
semantic_parameters
.get("cursor")
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned),
FINAL_CACHE_POLICY_REVISION,
FINAL_CACHE_EXTENSION_REVISION,
FINAL_CACHE_REPRESENTATION_POLICY_REVISION,
FINAL_CACHE_LIMITS_POLICY_REVISION,
CachePartitionKey::new("http-client-connection"),
result_set,
))
}
/// Sends one request with the next client-owned JSON-RPC request ID.
pub async fn request(
&mut self,
cx: &Cx,
method: impl AsRef<str>,
parameters: serde_json::Value,
) -> Result<ClientHttpResponse, HttpClientError> {
let request_id = self.next_request_id()?;
self.connection
.request(cx, method, parameters, request_id)
.await
.map_err(HttpClientError::Connection)
}
/// Sends one notification through the selected HTTP era.
pub async fn notify(
&mut self,
cx: &Cx,
method: impl AsRef<str>,
parameters: Option<serde_json::Value>,
) -> Result<(), HttpClientError> {
self.connection
.notify(cx, method, parameters)
.await
.map_err(HttpClientError::Connection)
}
}
fn final_cache_result_set(request: &CoreRequest) -> Option<FinalCacheResultSet> {
let CoreRequest::Final(request) = request else {
return None;
};
match request {
FinalCoreRequest::ToolsList(_) => Some(FinalCacheResultSet::Tools),
FinalCoreRequest::ResourcesList(_) => Some(FinalCacheResultSet::Resources),
FinalCoreRequest::ResourceTemplatesList(_) => Some(FinalCacheResultSet::ResourceTemplates),
FinalCoreRequest::ResourcesRead(params) => Some(FinalCacheResultSet::Resource(
params.uri.as_str().to_owned(),
)),
FinalCoreRequest::PromptsList(_) => Some(FinalCacheResultSet::Prompts),
_ => None,
}
}
/// An MCP client instance.
///
/// Clients are built using [`ClientBuilder`] and own a stdio subprocess
/// transport. Use [`HttpClient`] for policy-bound HTTP composition.
const MAX_FINAL_CACHE_TTL_DIAGNOSTICS: usize = 64;
#[cfg(unix)]
const FINAL_CACHE_NOTIFICATION_DRAIN_WINDOW: Duration = Duration::from_millis(1);
const FINAL_CACHE_POLICY_REVISION: u64 = 1;
const FINAL_CACHE_EXTENSION_REVISION: u64 = 1;
const FINAL_CACHE_REPRESENTATION_POLICY_REVISION: u64 = 1;
const FINAL_CACHE_LIMITS_POLICY_REVISION: u64 = 1;
const FINAL_CACHE_LIST_RESTART_LIMIT_ERROR: &str =
"Final list changed while rebuilding its cache-consistent page set";
#[derive(Clone, Copy, Debug)]
struct FinalCachePageState {
generation: FinalCacheGeneration,
scope: fastmcp_protocol::CacheScope,
miss: Option<FinalCacheMiss>,
}
/// Raw response bytes paired with the monotonic instant at which the response
/// became available to this client.
struct ReceivedPreparedResult {
result: serde_json::Value,
raw_result: Option<String>,
receipt: Instant,
}
/// A cloneable, request-owning executor for the selected stdio connection.
///
/// Clones share one ID allocator, response/tombstone registry, and writer.
/// They never own a receive half: [`Client::drive_multiplexed_stdio`] is the
/// sole connection ingress driver and delivers exact admitted frames to this
/// executor. That prevents a handle wait from racing a Client convenience API
/// for the next stdio frame.
#[derive(Clone)]
pub struct StdioRequestExecutor {
executor: RequestExecutor<SelectedStdioTransport>,
next_id: Arc<AtomicU64>,
timeout_policy: Arc<Mutex<RequestTimeoutPolicy>>,
peer_era: ProtocolEra,
modern_request_meta: Option<Arc<FinalRequestMeta>>,
client_extension_runtime: Option<Arc<ClientExtensionRuntime>>,
}
impl std::fmt::Debug for StdioRequestExecutor {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("StdioRequestExecutor")
.field("peer_era", &self.peer_era)
.finish_non_exhaustive()
}
}
/// One request committed through [`StdioRequestExecutor`].
pub struct StdioRequestExecution {
execution: RequestExecution<SelectedStdioTransport>,
}
/// One incrementally driven final Tasks subscription on a stdio client.
///
/// It deliberately remains inside [`Client`]: only the client owns the
/// connection ingress driver, so returning a borrowing listener would invite a
/// second reader to race the request correlation registry.
#[cfg(feature = "tasks")]
struct LiveStdioTaskSubscription {
executor: StdioRequestExecutor,
execution: StdioRequestExecution,
acknowledgement_delivered: bool,
pending_notifications: VecDeque<FinalTaskStatusNotification>,
/// A failed cancellation control leaves this listener owned by the client.
/// Retain the exact failure so a later poll cannot pretend cancellation
/// committed after the upstream writer had already failed.
cancellation_failure: Option<McpError>,
}
/// One incrementally driven final catalog subscription on a stdio client.
///
/// Like the Tasks listener, this stays inside [`Client`] so the same connection
/// can keep issuing ordinary requests while catalog events are drained one at
/// a time. A borrowing listener would occupy ingress and freeze `tools/call`.
struct LiveStdioCatalogSubscription {
executor: StdioRequestExecutor,
execution: StdioRequestExecution,
core_request: CoreRequest,
requested_filter: SubscriptionFilter,
accepted_filter: Option<SubscriptionFilter>,
acknowledgement_delivered: bool,
pending_notifications: VecDeque<ServerNotification>,
/// A failed cancellation control leaves this listener owned by the client.
/// Retain the exact failure so a later poll cannot pretend cancellation
/// committed after the upstream writer had already failed.
cancellation_failure: Option<McpError>,
}
/// One incrementally observed stdio catalog subscription event.
#[allow(
clippy::large_enum_variant,
reason = "this public event deliberately exposes an owned typed catalog notification so callers can inspect it without an extra allocation or a changed match surface"
)]
#[derive(Debug, Clone)]
pub enum StdioSubscriptionEvent {
/// The upstream accepted the exact filter for this request.
Acknowledged(SubscriptionFilter),
/// One typed catalog or resource-update event, in upstream arrival order.
Notification(ServerNotification),
/// The correlated listener reached its validated final completion result.
Terminal,
}
/// One incrementally observed stdio Tasks subscription event.
#[cfg(feature = "tasks")]
#[allow(
clippy::large_enum_variant,
reason = "this public event deliberately exposes an owned typed Tasks notification so callers can inspect it without an extra allocation or a changed match surface"
)]
#[derive(Debug)]
pub enum StdioTaskSubscriptionEvent {
/// The upstream accepted the exact filter for this request.
Acknowledged(SubscriptionFilter),
/// One typed status update, in upstream arrival order.
Notification(FinalTaskStatusNotification),
/// The correlated listener reached its validated final completion result.
Terminal,
}
impl std::fmt::Debug for StdioRequestExecution {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("StdioRequestExecution")
.field("request_id", self.request_id())
.field("generation", &self.execution.generation())
.finish()
}
}
impl StdioRequestExecution {
/// Returns the JSON-RPC ID committed for this request.
#[must_use]
pub fn request_id(&self) -> &RequestId {
self.execution.request_id()
}
/// Drains this execution's admitted progress notifications in arrival
/// order without reading the connection.
pub fn take_stream_notifications(&mut self) -> McpResult<Vec<JsonRpcRequest>> {
self.execution.take_stream_notifications()
}
}
impl StdioRequestExecutor {
fn new(
transport: SelectedStdioTransport,
next_id: Arc<AtomicU64>,
peer_era: ProtocolEra,
timeout_policy: RequestTimeoutPolicy,
modern_request_meta: Option<FinalRequestMeta>,
client_extension_runtime: Option<Arc<ClientExtensionRuntime>>,
) -> Self {
Self {
executor: RequestExecutor::with_protocol_era(transport, peer_era),
next_id,
timeout_policy: Arc::new(Mutex::new(timeout_policy)),
peer_era,
modern_request_meta: modern_request_meta.map(Arc::new),
client_extension_runtime,
}
}
/// Returns the immutable era selected by the completed handshake.
#[must_use]
pub const fn selected_protocol_era(&self) -> ProtocolEra {
self.peer_era
}
/// Commits one raw JSON-RPC request and returns its request-owned handle.
///
/// The selected connection allocates the ID itself, so executor clones and
/// the Client's sequential APIs stay in one monotonic correlation space.
/// A completed handle is observed with [`Self::try_take_response`] after
/// the owning [`Client`] drives ingress.
///
/// A Modern2026 executor stamps the negotiated protocol version and
/// advertised client capabilities when the caller omitted them. Already
/// present `_meta` keys are left unchanged.
pub fn execute(
&self,
cx: &Cx,
method: impl Into<String>,
params: Option<serde_json::Value>,
) -> McpResult<StdioRequestExecution> {
let method = method.into();
if self
.client_extension_runtime
.as_ref()
.is_some_and(|runtime| runtime.owns_method(&method))
{
return Err(McpError::invalid_params(
"Registered final extension methods require Client::request_final_extension",
));
}
let params = self.decorate_modern_request_parameters(params)?;
let id = next_stdio_request_id(&self.next_id)?;
let id = i64::try_from(id).expect("client request ID allocator enforces the i64 bound");
self.execute_request(cx, JsonRpcRequest::new(method, params, id))
}
fn execute_request(
&self,
cx: &Cx,
request: JsonRpcRequest,
) -> McpResult<StdioRequestExecution> {
let timeout_policy = *self.timeout_policy.lock().map_err(|_| {
McpError::internal_error("Negotiated stdio timeout policy is unavailable")
})?;
let execution = self
.executor
.execute_with_timeout_policy(cx, request, timeout_policy)?;
Ok(StdioRequestExecution { execution })
}
fn decorate_modern_request_parameters(
&self,
params: Option<serde_json::Value>,
) -> McpResult<Option<serde_json::Value>> {
let Some(final_metadata) = self.modern_request_meta.as_ref() else {
return Ok(params);
};
let mut params = params.unwrap_or_else(|| serde_json::json!({}));
let object = params.as_object_mut().ok_or_else(|| {
McpError::invalid_params("Modern MCP requests require object parameters")
})?;
let metadata = object
.entry("_meta")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
let metadata = metadata.as_object_mut().ok_or_else(|| {
McpError::invalid_params("Modern MCP request metadata must be an object")
})?;
let stamped = serde_json::to_value(final_metadata.as_ref()).map_err(|error| {
McpError::internal_error(format!(
"Failed to serialize modern request metadata: {error}"
))
})?;
let stamped = stamped.as_object().ok_or_else(|| {
McpError::internal_error("Modern request metadata did not serialize as an object")
})?;
for (key, value) in stamped {
metadata.entry(key.clone()).or_insert(value.clone());
}
Ok(Some(params))
}
/// Commits one already-prepared final Tasks subscription request.
///
/// The request remains owned by the returned handle. Its acknowledgement
/// and typed task updates are routed only when the parent [`Client`]
/// drives the selected stdio ingress.
#[cfg(feature = "tasks")]
pub fn execute_tasks_subscription(
&self,
cx: &Cx,
request: JsonRpcRequest,
) -> McpResult<StdioRequestExecution> {
let execution = self.executor.execute_tasks_subscription(cx, request)?;
Ok(StdioRequestExecution { execution })
}
/// Allocates and commits one prepared final Tasks subscription request.
#[cfg(feature = "tasks")]
pub fn execute_final_tasks_subscription(
&self,
cx: &Cx,
parameters: serde_json::Value,
) -> McpResult<StdioRequestExecution> {
let id = next_stdio_request_id(&self.next_id)?;
let id = i64::try_from(id).expect("client request ID allocator enforces the i64 bound");
self.execute_tasks_subscription(
cx,
JsonRpcRequest::new("subscriptions/listen", Some(parameters), id),
)
}
/// Returns the filter acknowledged for an active Tasks subscription.
#[cfg(feature = "tasks")]
pub fn tasks_subscription_acknowledgement(
&self,
execution: &StdioRequestExecution,
) -> McpResult<Option<SubscriptionFilter>> {
self.executor
.tasks_subscription_acknowledgement(&execution.execution)
}
/// Drains the typed Tasks updates already admitted for one subscription.
#[cfg(feature = "tasks")]
pub fn take_tasks_subscription_notifications(
&self,
execution: &StdioRequestExecution,
) -> McpResult<Vec<FinalTaskStatusNotification>> {
self.executor
.take_tasks_subscription_notifications(&execution.execution)
}
/// Takes the terminal subscription response if ingress has already
/// delivered it. This method never performs a transport read.
#[cfg(feature = "tasks")]
pub fn try_take_tasks_subscription_terminal(
&self,
execution: &mut StdioRequestExecution,
) -> McpResult<Option<SubscriptionFilter>> {
self.executor
.try_take_tasks_subscription_terminal(&mut execution.execution)
}
/// Sends the selected-era cancellation control for one live subscription.
#[cfg(feature = "tasks")]
pub fn cancel_tasks_subscription(
&self,
cx: &Cx,
execution: &mut StdioRequestExecution,
) -> McpResult<()> {
self.executor.cancel(cx, &mut execution.execution)
}
/// Selects caller cancellation for one ordinary request-owned execution.
///
/// The selected stdio client keeps this internal so its connection-owned
/// ingress driver remains the sole authority that services the resulting
/// terminal transition.
fn cancel(&self, cx: &Cx, execution: &mut StdioRequestExecution) -> McpResult<()> {
self.executor.cancel(cx, &mut execution.execution)
}
/// Takes one already-routed final response without reading the transport.
pub fn try_take_response(
&self,
execution: &mut StdioRequestExecution,
) -> McpResult<Option<JsonRpcResponse>> {
let Some(response) = self.executor.try_take_response(&mut execution.execution)? else {
return Ok(None);
};
if let Some(error) = response.error.clone() {
return Err(json_rpc_error_to_mcp(error));
}
Ok(Some(response))
}
/// Takes one already-routed final response with its exact admitted result
/// source, without reading the transport.
pub fn try_take_response_with_raw_result(
&self,
execution: &mut StdioRequestExecution,
) -> McpResult<Option<(JsonRpcResponse, Option<String>)>> {
let Some((response, raw_result)) = self
.executor
.try_take_response_with_raw_result(&mut execution.execution)?
else {
return Ok(None);
};
if let Some(error) = response.error.clone() {
return Err(json_rpc_error_to_mcp(error));
}
Ok(Some((response, raw_result)))
}
fn service(&self, cx: &Cx) -> McpResult<()> {
self.executor.poll_timeouts_at(cx, Instant::now())
}
fn service_at(&self, cx: &Cx, observed_at: Instant) -> McpResult<()> {
self.executor.poll_timeouts_at(cx, observed_at)
}
fn next_pending_deadline(&self) -> Option<Instant> {
self.executor.next_pending_deadline()
}
fn owns_response_id(&self, response_id: &RequestId) -> bool {
self.executor.owns_response_id(response_id)
}
fn owns_progress_notification(&self, notification: &JsonRpcRequest) -> bool {
self.executor.owns_progress_notification(notification)
}
fn owns_modern_subscription_cancellation(&self, notification: &JsonRpcRequest) -> bool {
self.executor
.owns_modern_subscription_cancellation(notification)
}
#[cfg(feature = "tasks")]
fn owns_task_subscription_notification(&self, notification: &JsonRpcRequest) -> bool {
self.executor
.owns_task_subscription_notification(notification)
}
/// Returns whether an admitted selected-stdio notification belongs to a
/// live request owner. Every selected-stdio ingress branch must use this
/// common predicate so Tasks status notifications reach their listener
/// instead of falling through to connection-level routing.
fn owns_selected_stdio_notification(&self, notification: &JsonRpcRequest) -> bool {
let owns = self.owns_progress_notification(notification)
|| self.owns_modern_subscription_cancellation(notification);
#[cfg(feature = "tasks")]
let owns = owns || self.owns_task_subscription_notification(notification);
owns
}
fn drive_frame(&self, cx: &Cx, frame: ReceivedTransportFrame) -> McpResult<()> {
self.executor.drive_frame(cx, frame)
}
fn fail_connection(&self, error: McpError) {
self.executor.fail_connection(error);
}
fn set_timeout_policy(&self, timeout_policy: RequestTimeoutPolicy) -> McpResult<()> {
*self.timeout_policy.lock().map_err(|_| {
McpError::internal_error("Negotiated stdio timeout policy is unavailable")
})? = timeout_policy;
Ok(())
}
}
fn next_stdio_request_id(next_id: &AtomicU64) -> McpResult<u64> {
next_id
.try_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
current
.checked_add(1)
.filter(|next| *next <= REQUEST_ID_EXHAUSTION_SENTINEL)
})
.map_err(|_| McpError::internal_error("Client request ID space exhausted"))
}
/// A negotiated stdio MCP client.
///
/// # Runtime use
///
/// The synchronous convenience requests block their caller while the sole
/// stdio ingress waits for a frame. They therefore cannot make progress on an
/// exact-2024 reverse callback when invoked from an asupersync
/// `RuntimeBuilder::current_thread()` task. On Unix,
/// `Client::request_with_cx` retains that sole ingress owner while
/// cooperatively yielding between bounded stdio readiness polls so the owned
/// reverse callback tasks can run. Non-Unix child pipes do not expose a safe
/// bounded readiness primitive here, so no corresponding async stdio request
/// API is available there.
pub struct Client {
/// The subprocess running the MCP server.
child: Option<Child>,
/// Live Unix group anchor and owner-death control descriptor.
group_anchor: Option<ProcessGroupAnchor>,
/// Scope that explicit shutdown must terminate and reap.
child_ownership: ChildOwnership,
/// Retry-safe cleanup phase for the retained subprocess identity.
child_cleanup_phase: ClientChildCleanupPhase,
/// Cleanup failure retained after a terminal connection error has already
/// consumed the child handle. Explicit `close` must still surface it.
cleanup_error: Option<McpError>,
/// Latest retryable process-cleanup failure. This is cleared when a later
/// close proves that the retained ownership scope is quiescent.
pending_process_cleanup_error: Option<McpError>,
/// Independently owned stdio reader. Callback workers never borrow this
/// half, so the sole reader can continue admitting cancellation frames.
/// The sequential adapter and cloned multiplexed request handles share
/// this sole ingress half. A reader turn, rather than this state lock,
/// serializes blocking reads.
transport: Arc<Mutex<StdioRecvHalf<ChildStdout>>>,
/// Serializes every outbound frame, including callback completions the
/// sole reader commits between bounded receive polls.
response_sender: Arc<Mutex<StdioSendHalf<ChildStdin>>>,
/// Transport-neutral selected I/O, installed only after the stdio
/// connection selects its immutable protocol era. Its shared halves retain
/// the existing independently locked reader and writer ownership.
selected_io: Option<NegotiatedClientIo<SharedStdioRecv, SharedStdioSend>>,
/// Installed only after a final stdio handshake selected its immutable
/// peer era. Auto's disposable modern probe never reaches this field.
multiplexed_executor: Option<StdioRequestExecutor>,
/// At most one live final Tasks subscription owns the sequential stdio
/// listener surface. It is driven by this client's sole ingress method.
#[cfg(feature = "tasks")]
live_task_subscription: Option<LiveStdioTaskSubscription>,
/// At most one live final catalog subscription owns the incremental stdio
/// listener surface. It is driven by this client's sole ingress method so
/// ordinary requests can still complete while events are drained.
live_catalog_subscription: Option<LiveStdioCatalogSubscription>,
/// Capability context for cancellation.
cx: Cx,
/// Session state after initialization.
session: ClientSession,
/// Request ID counter.
next_id: Arc<AtomicU64>,
/// Strict response correlation for every in-flight request.
responses: SharedResponseRegistry,
/// Exact non-progress notifications received from a modern server.
final_server_notifications: VecDeque<ServerNotification>,
/// Exact-2024 server notifications retained from the stdio receive pump.
///
/// Modern sessions leave this empty and use
/// [`Self::take_final_server_notifications`] instead.
legacy_server_notifications: VecDeque<JsonRpcRequest>,
/// Exact progress notifications received from a modern server without
/// converting their JSON numbers to legacy `f64` values.
final_progress_notifications: VecDeque<FinalProgressNotificationParams>,
/// Bounded final complete-result cache scoped to this client connection.
final_result_cache: FinalResultCache,
/// Bounded compatibility diagnostics for immediately-stale peer TTLs.
final_cache_ttl_diagnostics: VecDeque<FinalCacheTtlDiagnostic>,
/// Receipt captured as soon as a typed core response reaches this client.
last_core_result_receipt: Option<Instant>,
/// Per-page provenance retained only until the immediate list aggregator
/// consumes it.
last_final_cache_page: Option<FinalCachePageState>,
/// Application handlers for server-initiated requests on this connection.
reverse_request_handlers: ReverseRequestHandlers,
/// Inbound request context for as_proxy reverse `sampling/createMessage`
/// / `roots/list` forwarding. The builder installs handlers that close
/// over this same slot before initialize.
inbound_legacy_reverse: Arc<Mutex<Option<McpContext>>>,
/// Fixed, owned callback workers for exact-2024 reverse requests.
reverse_callback_pool: ReverseCallbackPool,
/// Idle/absolute policy for ordinary stdio responses.
///
/// Unix child pipes use bounded readiness polling, including while a peer
/// is silent or holds a partial frame. On non-Unix targets, the standard
/// child pipe has no portable safe readiness primitive, so the deadline is
/// still observed only at complete-frame boundaries; synchronous response
/// writes to child stdin are likewise not preemptible there. Bounded atomic
/// cancellation controls are also unavailable there, so a required cancel
/// or timeout control fails the connection explicitly.
timeout_policy: RequestTimeoutPolicy,
/// Whether auto-initialization is enabled (for documentation/debugging).
#[allow(dead_code)]
auto_initialize: bool,
/// Whether the client has been initialized.
initialized: AtomicBool,
/// Terminal auto-initialization failure, preventing lifecycle retries on
/// the same subprocess connection.
initialization_error: Option<McpError>,
/// Final logging configuration included in metadata of later modern
/// requests. Exact legacy sessions send the historical RPC instead.
final_log_level: Option<LoggingLevel>,
}
/// Successful client negotiation, kept in its protocol-native shape until it
/// is committed to session state.
// Held protocol-native for one short negotiation window per client; boxing
// would touch every construction and match site for no allocation win.
#[allow(clippy::large_enum_variant)]
enum ClientInitialization {
/// Exact 2024-11-05 initialization response.
Legacy(InitializeResult),
/// Final `server/discover` response plus its required server identity.
Modern {
server_info: ServerInfo,
discovery: ServerDiscoverResult,
},
}
impl ClientInitialization {
fn protocol_version(&self) -> &str {
match self {
Self::Legacy(result) => &result.protocol_version,
Self::Modern { .. } => MODERN_PROTOCOL_VERSION,
}
}
}
impl Client {
fn retain_cleanup_error(&mut self, error: McpError) {
self.cleanup_error = Some(match self.cleanup_error.take() {
Some(previous) => combine_cleanup_errors(previous, error),
None => error,
});
}
fn stop_direct_peer(&mut self) -> McpResult<()> {
let Some(mut child) = self.child.take() else {
return Ok(());
};
let result = stop_direct_child(&mut child);
match result {
Ok(()) => Ok(()),
Err(error) => match child.try_wait() {
Ok(Some(_)) => Ok(()),
Ok(None) | Err(_) => {
self.child = Some(child);
Err(error)
}
},
}
}
fn stop_direct_owned_child(&mut self) -> McpResult<()> {
let result = self.stop_direct_peer();
if result.is_ok() {
self.child_cleanup_phase = ClientChildCleanupPhase::Complete;
}
result
}
#[cfg(unix)]
fn stop_owned_child_group(&mut self) -> McpResult<()> {
loop {
match self.child_cleanup_phase {
ClientChildCleanupPhase::Active => {
let Some(anchor) = self.group_anchor.as_mut() else {
let missing_anchor =
McpError::internal_error("Owned process-group cleanup lost its anchor");
let peer_result = self.stop_direct_peer();
if peer_result.is_ok() {
self.child_cleanup_phase = ClientChildCleanupPhase::Complete;
}
return combine_cleanup_results(Err(missing_anchor), peer_result);
};
match request_anchored_group_shutdown(anchor)? {
AnchoredGroupShutdown::KillAccepted(process_group) => {
self.child_cleanup_phase =
ClientChildCleanupPhase::GroupKillAccepted(process_group);
}
AnchoredGroupShutdown::IdentityLost(process_group) => {
self.child_cleanup_phase =
ClientChildCleanupPhase::GroupIdentityLost(process_group);
}
}
}
ClientChildCleanupPhase::GroupKillAccepted(process_group) => {
let peer_result = self.child.as_mut().map_or(Ok(()), reap_signalled_child);
if peer_result.is_ok() {
self.child = None;
}
let anchor_result = self.group_anchor.as_mut().map_or_else(
|| {
Err(McpError::internal_error(
"Owned process-group cleanup lost its anchor",
))
},
ProcessGroupAnchor::reap,
);
combine_cleanup_results(peer_result, anchor_result)?;
self.child_cleanup_phase =
ClientChildCleanupPhase::GroupChildrenReaped(process_group);
}
ClientChildCleanupPhase::GroupChildrenReaped(process_group) => {
wait_for_owned_process_group_quiescence(process_group)?;
self.child_cleanup_phase = ClientChildCleanupPhase::Complete;
return Ok(());
}
ClientChildCleanupPhase::GroupIdentityLost(process_group) => {
let peer_result = self.stop_direct_peer();
let group_result = require_owned_process_group_absent(process_group);
let result = combine_cleanup_results(peer_result, group_result);
if result.is_ok() {
self.child_cleanup_phase = ClientChildCleanupPhase::Complete;
}
return result;
}
ClientChildCleanupPhase::Complete => return Ok(()),
}
}
}
#[cfg(not(unix))]
fn stop_owned_child_group(&mut self) -> McpResult<()> {
Err(McpError::internal_error(
"Owned subprocess groups are unavailable on this platform",
))
}
fn stop_retained_child(&mut self) -> McpResult<()> {
if self.child_cleanup_phase == ClientChildCleanupPhase::Complete {
if self.child.is_none() {
return Ok(());
}
log::error!(
"Repairing an invalid completed-cleanup state that retained a direct child handle"
);
return self.stop_direct_peer();
}
match self.child_ownership {
ChildOwnership::DirectChild => self.stop_direct_owned_child(),
ChildOwnership::OwnedProcessGroup => self.stop_owned_child_group(),
}
}
/// Creates a client connecting to a subprocess via stdio.
///
/// # Arguments
///
/// * `command` - The command to run (e.g., "uvx", "npx")
/// * `args` - Arguments to pass to the command
///
/// # Errors
///
/// Returns an error if the subprocess fails to start or initialization fails.
pub fn stdio(command: &str, args: &[&str]) -> McpResult<Self> {
block_on(async {
let cx = Cx::current().expect("fastmcp runtime should install a current Cx");
Self::stdio_with_cx(command, args, cx)
})
}
/// Creates a client with a provided Cx for cancellation support.
pub fn stdio_with_cx(command: &str, args: &[&str], cx: Cx) -> McpResult<Self> {
// The public convenience constructor follows the same modern-first,
// bounded Auto selection as ClientBuilder. A correlated discovery
// MethodNotFound or Unix-observable clean first-probe timeout closes
// the disposable child before opening one fresh exact-2024 child;
// malformed discoveries and every other failure are never replayed as
// legacy traffic.
Self::stdio_with_protocol_plan_with_cx(
command,
args,
ClientProtocolPlan::stdio(DEFAULT_STDIO_PROTOCOL_POLICY),
cx,
)
}
/// Creates a stdio client from an immutable protocol plan.
///
/// `ModernOnly` performs a modern `server/discover` exchange, while
/// `LegacyOnly` performs the exact 2024-11-05 initialization lifecycle.
/// `Auto` first probes a disposable modern process and starts a fresh
/// exact-2024 process only for a correlated discovery refusal or an
/// Unix-observable clean first-probe timeout. Transport failures and
/// malformed modern discovery never authorize a downgrade.
pub fn stdio_with_protocol_plan(
command: &str,
args: &[&str],
protocol_plan: ClientProtocolPlan,
) -> McpResult<Self> {
block_on(async {
let cx = Cx::current().expect("fastmcp runtime should install a current Cx");
Self::stdio_with_protocol_plan_with_cx(command, args, protocol_plan, cx)
})
}
/// Creates a plan-aware stdio client with a caller-provided cancellation
/// context.
pub fn stdio_with_protocol_plan_with_cx(
command: &str,
args: &[&str],
protocol_plan: ClientProtocolPlan,
cx: Cx,
) -> McpResult<Self> {
validate_protocol_plan_feature(&protocol_plan)?;
match protocol_plan.policy() {
ProtocolPolicy::ModernOnly | ProtocolPolicy::LegacyOnly => {
Self::connect_stdio_with_protocol_plan_once(command, args, protocol_plan, cx)
}
// The public constructor deliberately shares the builder's
// bounded Auto probe. That path owns the one clean-timeout signal,
// correlated refusal validation, and fresh-child lifecycle; fixed
// selected-era constructors retain this direct lightweight path.
ProtocolPolicy::Auto => ClientBuilder::new()
.protocol_plan(protocol_plan)
.connect_stdio_with_cx(command, args, &cx),
}
}
fn connect_stdio_with_protocol_plan_once(
command: &str,
args: &[&str],
protocol_plan: ClientProtocolPlan,
cx: Cx,
) -> McpResult<Self> {
if cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
// Spawn the subprocess
let executable = resolve_stdio_command(command, None)?;
let mut command = Command::new(executable);
command
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
let child = command
.spawn()
.map_err(|e| McpError::internal_error(format!("Failed to spawn subprocess: {e}")))?;
let mut child_guard = ChildGuard::new(child);
// Get stdin/stdout handles
let stdin = match child_guard.child_mut().stdin.take() {
Some(stdin) => stdin,
None => {
return combine_operation_and_cleanup(
Err(McpError::internal_error("Failed to get subprocess stdin")),
child_guard.cleanup(),
);
}
};
let stdout = match child_guard.child_mut().stdout.take() {
Some(stdout) => stdout,
None => {
return combine_operation_and_cleanup(
Err(McpError::internal_error("Failed to get subprocess stdout")),
child_guard.cleanup(),
);
}
};
// Create transport
let transport = StdioTransport::new(stdout, stdin);
// Create client info
let client_info = ClientInfo {
name: "fastmcp-client".to_owned(),
version: env!("CARGO_PKG_VERSION").to_owned(),
};
let client_capabilities = ClientCapabilities::default();
let (transport, response_sender) = transport.into_split();
let transport = Arc::new(Mutex::new(transport));
let response_sender = Arc::new(Mutex::new(response_sender));
let reverse_callback_pool =
ReverseCallbackPool::new(Arc::clone(&response_sender), cx.clone());
// Create a temporary client for initialization
let mut client = Self {
child: Some(child_guard.disarm()),
group_anchor: None,
child_ownership: ChildOwnership::DirectChild,
child_cleanup_phase: ClientChildCleanupPhase::Active,
cleanup_error: None,
pending_process_cleanup_error: None,
transport,
response_sender,
selected_io: None,
multiplexed_executor: None,
#[cfg(feature = "tasks")]
live_task_subscription: None,
live_catalog_subscription: None,
cx,
session: ClientSession::new_placeholder(
client_info.clone(),
client_capabilities.clone(),
ServerInfo {
name: String::new(),
version: String::new(),
},
ServerCapabilities::default(),
)
.with_protocol_plan(protocol_plan.clone()),
// `initialize()` consumes ID 1 through the same monotonic
// allocator, leaving ID 2 as the first ordinary request ID.
next_id: Arc::new(AtomicU64::new(1)),
responses: SharedResponseRegistry::new(),
final_server_notifications: VecDeque::new(),
legacy_server_notifications: VecDeque::new(),
final_progress_notifications: VecDeque::new(),
final_result_cache: FinalResultCache::default(),
final_cache_ttl_diagnostics: VecDeque::new(),
last_core_result_receipt: None,
last_final_cache_page: None,
reverse_request_handlers: ReverseRequestHandlers::new(),
reverse_callback_pool,
timeout_policy: RequestTimeoutPolicy::default(),
auto_initialize: false,
initialized: AtomicBool::new(false),
initialization_error: None,
final_log_level: None,
inbound_legacy_reverse: Arc::new(Mutex::new(None)),
};
// Perform initialization handshake
let initialization = match client.initialize(client_info, client_capabilities) {
Ok(result) => result,
Err(error) => {
let cleanup = client.close();
return combine_operation_and_cleanup(Err(error), cleanup);
}
};
let init_protocol_version = initialization.protocol_version().to_owned();
if let Err(error) = client.replace_session_after_initialization(initialization) {
let cleanup = client.close();
return combine_operation_and_cleanup(Err(error), cleanup);
}
// Send the spec-correct `notifications/initialized` lifecycle notification.
if init_protocol_version == PROTOCOL_VERSION
&& let Err(error) = client.send_initialized_notification()
{
let cleanup = client.close();
return combine_operation_and_cleanup(Err(error), cleanup);
}
client.activate_selected_io();
// Mark as initialized
client.initialized.store(true, Ordering::SeqCst);
client.install_multiplexed_stdio_executor();
Ok(client)
}
fn set_protocol_plan_after_selection(&mut self, protocol_plan: ClientProtocolPlan) {
let selected_era = self.session.selected_era();
self.session.set_protocol_plan(protocol_plan);
debug_assert_eq!(self.session.selected_era(), selected_era);
}
/// Creates a new client builder.
#[must_use]
pub fn builder() -> ClientBuilder {
ClientBuilder::new()
}
/// Connects a ready high-level HTTP client from an immutable protocol plan.
///
/// This is the HTTP counterpart to [`Self::stdio_with_protocol_plan`].
/// The returned [`HttpClient`] owns policy selection and completes the
/// legacy lifecycle before allowing ordinary application requests.
pub fn http(protocol_plan: ClientProtocolPlan) -> Result<HttpClient, HttpClientError> {
ClientBuilder::new()
.protocol_plan(protocol_plan)
.connect_http_client()
}
/// Connects a ready high-level HTTP client with an explicit cancellation context.
pub async fn http_with_cx(
protocol_plan: ClientProtocolPlan,
cx: &Cx,
) -> Result<HttpClient, HttpClientError> {
ClientBuilder::new()
.protocol_plan(protocol_plan)
.connect_http_client_with_cx(cx)
.await
}
/// Connects one exact MCP 2024-11-05 HTTP+SSE client.
///
/// This is the standalone SSE constructor for callers who already know the
/// GET event stream and POST message endpoints. Auto still uses SSE only
/// as a fallback after a modern HTTP probe; this method never probes
/// MCP 2026-07-28.
#[cfg(feature = "legacy-2024-11-05")]
pub fn sse(
sse_endpoint: CanonicalHttpUrl,
message_post_endpoint: CanonicalHttpUrl,
) -> Result<HttpClient, HttpClientError> {
Self::http(Self::legacy_sse_plan(sse_endpoint, message_post_endpoint)?)
}
/// Connects one exact MCP 2024-11-05 HTTP+SSE client with an explicit
/// cancellation context.
#[cfg(feature = "legacy-2024-11-05")]
pub async fn sse_with_cx(
sse_endpoint: CanonicalHttpUrl,
message_post_endpoint: CanonicalHttpUrl,
cx: &Cx,
) -> Result<HttpClient, HttpClientError> {
Self::http_with_cx(
Self::legacy_sse_plan(sse_endpoint, message_post_endpoint)?,
cx,
)
.await
}
#[cfg(feature = "legacy-2024-11-05")]
fn legacy_sse_plan(
sse_endpoint: CanonicalHttpUrl,
message_post_endpoint: CanonicalHttpUrl,
) -> Result<ClientProtocolPlan, HttpClientError> {
ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(sse_endpoint),
Some(message_post_endpoint),
"fastmcp-rust-client-sse".to_owned(),
"fastmcp-rust-client-sse".to_owned(),
"legacy-2024-http-sse".to_owned(),
0,
0,
0,
)
.map_err(|error| HttpClientError::CoreResult(McpError::invalid_params(error.to_string())))
}
/// Creates a client from its component parts.
///
/// This is an internal constructor used by the builder.
pub(crate) fn from_parts(
child: Child,
transport: StdioTransport<ChildStdout, ChildStdin>,
cx: Cx,
session: ClientSession,
timeout_policy: RequestTimeoutPolicy,
) -> Self {
Self::from_parts_with_ownership(
child,
ChildOwnership::DirectChild,
None,
transport,
cx,
session,
timeout_policy,
)
}
pub(crate) fn from_parts_with_ownership(
child: Child,
child_ownership: ChildOwnership,
group_anchor: Option<ProcessGroupAnchor>,
transport: StdioTransport<ChildStdout, ChildStdin>,
cx: Cx,
session: ClientSession,
timeout_policy: RequestTimeoutPolicy,
) -> Self {
let (transport, response_sender) = transport.into_split();
let transport = Arc::new(Mutex::new(transport));
let response_sender = Arc::new(Mutex::new(response_sender));
let reverse_callback_pool =
ReverseCallbackPool::new(Arc::clone(&response_sender), cx.clone());
let selected_io = session.selected_era().map(|_| {
NegotiatedClientIo::new(
SharedStdioRecv(Arc::clone(&transport)),
SharedStdioSend(Arc::clone(&response_sender)),
)
});
let mut client = Self {
child: Some(child),
group_anchor,
child_ownership,
child_cleanup_phase: ClientChildCleanupPhase::Active,
cleanup_error: None,
pending_process_cleanup_error: None,
transport,
response_sender,
selected_io,
multiplexed_executor: None,
#[cfg(feature = "tasks")]
live_task_subscription: None,
live_catalog_subscription: None,
cx,
session,
next_id: Arc::new(AtomicU64::new(2)), // Start at 2 since initialize used 1
responses: SharedResponseRegistry::new(),
final_server_notifications: VecDeque::new(),
legacy_server_notifications: VecDeque::new(),
final_progress_notifications: VecDeque::new(),
final_result_cache: FinalResultCache::default(),
final_cache_ttl_diagnostics: VecDeque::new(),
last_core_result_receipt: None,
last_final_cache_page: None,
reverse_request_handlers: ReverseRequestHandlers::new(),
reverse_callback_pool,
timeout_policy,
auto_initialize: false,
initialized: AtomicBool::new(true), // Already initialized by builder
initialization_error: None,
final_log_level: None,
inbound_legacy_reverse: Arc::new(Mutex::new(None)),
};
client.install_multiplexed_stdio_executor();
client
}
/// Creates an uninitialized client for auto-initialize mode.
///
/// This is an internal constructor used by the builder when auto_initialize is enabled.
pub(crate) fn from_parts_uninitialized(
child: Child,
transport: StdioTransport<ChildStdout, ChildStdin>,
cx: Cx,
session: ClientSession,
timeout_policy: RequestTimeoutPolicy,
) -> Self {
Self::from_parts_uninitialized_with_ownership(
child,
ChildOwnership::DirectChild,
None,
transport,
cx,
session,
timeout_policy,
)
}
pub(crate) fn from_parts_uninitialized_with_ownership(
child: Child,
child_ownership: ChildOwnership,
group_anchor: Option<ProcessGroupAnchor>,
transport: StdioTransport<ChildStdout, ChildStdin>,
cx: Cx,
session: ClientSession,
timeout_policy: RequestTimeoutPolicy,
) -> Self {
let (transport, response_sender) = transport.into_split();
let transport = Arc::new(Mutex::new(transport));
let response_sender = Arc::new(Mutex::new(response_sender));
let reverse_callback_pool =
ReverseCallbackPool::new(Arc::clone(&response_sender), cx.clone());
Self {
child: Some(child),
group_anchor,
child_ownership,
child_cleanup_phase: ClientChildCleanupPhase::Active,
cleanup_error: None,
pending_process_cleanup_error: None,
transport,
response_sender,
selected_io: None,
multiplexed_executor: None,
#[cfg(feature = "tasks")]
live_task_subscription: None,
live_catalog_subscription: None,
cx,
session,
next_id: Arc::new(AtomicU64::new(1)), // Start at 1 since initialize hasn't happened
responses: SharedResponseRegistry::new(),
final_server_notifications: VecDeque::new(),
legacy_server_notifications: VecDeque::new(),
final_progress_notifications: VecDeque::new(),
final_result_cache: FinalResultCache::default(),
final_cache_ttl_diagnostics: VecDeque::new(),
last_core_result_receipt: None,
last_final_cache_page: None,
reverse_request_handlers: ReverseRequestHandlers::new(),
reverse_callback_pool,
timeout_policy,
auto_initialize: true,
initialized: AtomicBool::new(false),
initialization_error: None,
final_log_level: None,
inbound_legacy_reverse: Arc::new(Mutex::new(None)),
}
}
/// Ensures the client is initialized.
///
/// In auto-initialize mode, this performs the initialization handshake on first call.
/// In normal mode, this is a no-op since the client is already initialized.
///
/// Since this method takes `&mut self`, Rust's borrowing rules guarantee exclusive
/// access, so no additional synchronization is needed.
///
/// # Errors
///
/// Returns an error if initialization fails.
pub fn ensure_initialized(&mut self) -> McpResult<()> {
if let Err(error) = self.drain_completed_reverse_callbacks() {
return Err(self.terminate_connection(error));
}
if let Some(error) = self.responses.terminal_error() {
return Err(error);
}
// Already initialized - nothing to do
if self.initialized.load(Ordering::SeqCst) {
return Ok(());
}
if let Some(error) = &self.initialization_error {
return Err(error.clone());
}
// Perform initialization
let client_info = self.session.client_info().clone();
let capabilities = self.session.client_capabilities().clone();
let initialization = match self.initialize(client_info, capabilities) {
Ok(result) => result,
Err(error) => return Err(self.record_initialization_failure(error)),
};
self.complete_initialization(initialization)
.map_err(|error| self.record_initialization_failure(error))
}
/// Completes a disposable modern Auto probe without flattening its sole
/// eligible fallback signal into an [`McpError`]. The caller owns this
/// client until it either becomes selected or is closed before a fresh
/// legacy child is created.
pub(crate) fn ensure_initialized_for_auto_modern_probe(
&mut self,
) -> McpResult<Option<AutoStdioFallbackSignal>> {
if let Err(error) = self.drain_completed_reverse_callbacks() {
return Err(self.terminate_connection(error));
}
if let Some(error) = self.responses.terminal_error() {
return Err(error);
}
if self.initialized.load(Ordering::SeqCst) {
return Ok(None);
}
if let Some(error) = &self.initialization_error {
return Err(error.clone());
}
if self.session.protocol_plan().policy() != ProtocolPolicy::ModernOnly {
return Err(McpError::internal_error(
"Auto stdio probe requires a modern-only client session",
));
}
let client_info = self.session.client_info().clone();
let capabilities = self.session.client_capabilities().clone();
let initialization = match self.initialize_modern_for_auto_probe(client_info, capabilities)
{
Ok(Ok(initialization)) => initialization,
Ok(Err(signal)) => return Ok(Some(signal)),
Err(error) => return Err(self.record_initialization_failure(error)),
};
self.complete_initialization(initialization)
.map_err(|error| self.record_initialization_failure(error))?;
Ok(None)
}
fn complete_initialization(&mut self, initialization: ClientInitialization) -> McpResult<()> {
let init_protocol_version = initialization.protocol_version().to_owned();
self.replace_session_after_initialization(initialization)?;
// Exact 2024-11-05 transitions require the lifecycle acknowledgement.
// Modern discovery has no corresponding initialized notification.
if init_protocol_version == PROTOCOL_VERSION {
self.send_initialized_notification()?;
}
self.activate_selected_io();
self.initialized.store(true, Ordering::SeqCst);
self.install_multiplexed_stdio_executor();
Ok(())
}
/// Admits an API that returns an exact final result payload.
///
/// The selected era is immutable after initialization. Check it before
/// constructing request parameters or allocating a request ID so a legacy
/// session remains completely untouched by modern-only conveniences.
fn require_modern_final_result_session(&mut self, method: &str) -> McpResult<()> {
self.ensure_initialized()?;
if self.session.selected_era() == Some(ProtocolEra::Modern2026) {
return Ok(());
}
Err(McpError::invalid_params(format!(
"{method} exact final result is available only for MCP 2026-07-28"
)))
}
/// Admits an API that returns an exact legacy result payload.
///
/// The selected era is immutable after initialization. Check it before
/// constructing request parameters or allocating a request ID so a modern
/// session cannot be silently projected into the legacy vocabulary.
fn require_legacy_exact_result_session(&mut self, method: &str) -> McpResult<()> {
self.ensure_initialized()?;
if self.session.selected_era() == Some(ProtocolEra::Legacy2024) {
return Ok(());
}
Err(McpError::invalid_params(format!(
"{method} exact legacy result is available only for MCP 2024-11-05"
)))
}
fn record_initialization_failure(&mut self, error: McpError) -> McpError {
let error = self.terminate_connection(error);
self.initialization_error = Some(error.clone());
error
}
/// Permanently closes a subprocess connection after a connection-wide
/// protocol or I/O failure.
///
/// A partial write can corrupt NDJSON framing, and a malformed inbound
/// envelope makes peer state untrustworthy. Publish one terminal error to
/// every waiter before dropping stdin and reaping the owned child so later
/// public calls cannot retry on that connection.
fn terminate_connection(&mut self, error: McpError) -> McpError {
self.initialized.store(false, Ordering::SeqCst);
self.responses.fail_all(error.clone());
if let Some(executor) = &self.multiplexed_executor {
executor.fail_connection(error.clone());
}
self.cancel_reverse_callback_pool();
if let Err(cleanup_error) = self.join_reverse_callback_pool() {
// Do not attempt to lock or close stdin while a retained callback
// worker may still own its writer lock. The worker remains owned
// by this client and a later explicit close can retry the join.
self.retain_cleanup_error(cleanup_error);
return error;
}
if let Err(cleanup_error) = self.close_transport().map_err(transport_error_to_mcp) {
self.retain_cleanup_error(cleanup_error);
}
if let Err(cleanup_error) = self.stop_retained_child() {
log::error!("Subprocess cleanup failed after terminal client error: {cleanup_error}");
if self.child_cleanup_phase == ClientChildCleanupPhase::Complete {
self.pending_process_cleanup_error = None;
self.retain_cleanup_error(cleanup_error);
} else {
self.pending_process_cleanup_error = Some(cleanup_error);
}
} else {
self.pending_process_cleanup_error = None;
}
let cleanup = combine_cleanup_results(
self.cleanup_error.clone().map_or(Ok(()), Err),
self.pending_process_cleanup_error
.clone()
.map_or(Ok(()), Err),
);
combine_operation_and_cleanup::<()>(Err(error), cleanup)
.expect_err("a terminal operation error cannot become a successful cleanup result")
}
/// Returns whether the client has been initialized.
#[must_use]
pub fn is_initialized(&self) -> bool {
self.initialized.load(Ordering::SeqCst)
}
/// Returns the server info after initialization.
#[must_use]
pub fn server_info(&self) -> &ServerInfo {
self.session.server_info()
}
/// Returns whether final discovery activated the official MCP Apps extension.
#[cfg(feature = "apps")]
#[must_use]
pub const fn mcp_apps_active(&self) -> bool {
self.session.mcp_apps_active()
}
/// Starts one browser-agnostic Apps Host after successful Apps negotiation.
/// This never alters the MCP client/server RPC dispatcher.
#[cfg(feature = "apps")]
pub fn mcp_apps_host<T, P>(
&self,
transport: T,
configuration: McpAppsHostConfiguration,
policy: P,
) -> Result<McpAppsHost<T, P>, McpAppsHostError>
where
T: McpAppsBridgeTransport,
P: McpAppsHostPolicy,
{
let activation_proof = mcp_apps::McpAppsActivationProof::from_activation_receipt(
self.session.mcp_apps_activation_receipt(),
)?;
Ok(McpAppsHost::new_negotiated(
transport,
configuration,
policy,
activation_proof,
))
}
/// Starts the closed JSON-RPC Apps bridge after final discovery retained
/// the current bilateral activation receipt. Standard-reused View methods
/// become fresh selected-era core requests owned by this client.
#[cfg(feature = "apps")]
pub fn mcp_apps_wire_host<T>(
&mut self,
transport: T,
configuration: mcp_apps::McpAppsWireHostConfiguration,
) -> Result<mcp_apps::McpAppsWireHost<T, mcp_apps::McpAppsClientWirePolicy<'_>>, McpAppsHostError>
where
T: mcp_apps::McpAppsWireBridgeTransport,
{
let activation_proof = mcp_apps::McpAppsActivationProof::from_activation_receipt(
self.session.mcp_apps_activation_receipt(),
)?;
Ok(mcp_apps::McpAppsWireHost::new_negotiated(
transport,
configuration,
mcp_apps::McpAppsClientWirePolicy::new(self),
activation_proof,
))
}
/// Returns the server capabilities after initialization.
#[must_use]
pub fn server_capabilities(&self) -> &ServerCapabilities {
self.session.server_capabilities()
}
/// Returns the exact final `server/discover` result for a modern session.
///
/// This retains final capabilities, instructions, metadata, and cache
/// semantics without projecting them into the legacy initialization
/// result. Exact 2024-11-05 sessions return `None`.
#[must_use]
pub fn server_discovery(&self) -> Option<&ServerDiscoverResult> {
self.session.server_discovery()
}
/// Returns server instructions retained from the successful handshake.
///
/// Modern sessions prefer the final discovery string. Exact 2024-11-05
/// sessions return the initialize result field. A missing value means the
/// peer did not advertise instructions.
#[must_use]
pub fn instructions(&self) -> Option<&str> {
self.session.instructions()
}
/// Returns the generic final extension state frozen by the successful
/// `server/discover` exchange, if this client selected MCP 2026-07-28 and
/// was configured through [`ClientBuilder::extension_registry`].
#[must_use]
pub fn negotiated_extensions(
&self,
) -> Option<&fastmcp_protocol::extensions::NegotiatedExtensionSet> {
self.session.negotiated_extensions()
}
/// Returns the protocol version negotiated during initialization.
#[must_use]
pub fn protocol_version(&self) -> &str {
self.session.protocol_version()
}
/// Returns the immutable policy selected before this client connected.
#[must_use]
pub const fn protocol_policy(&self) -> ProtocolPolicy {
self.session.protocol_plan().policy()
}
/// Returns the era selected by the successful public initialization path.
///
/// `None` means that initialization has not completed or a connection
/// failed before a supported era was selected.
#[must_use]
pub const fn selected_protocol_era(&self) -> Option<ProtocolEra> {
self.session.selected_era()
}
/// Drains non-progress final server notifications received during modern requests.
///
/// Use [`Self::take_final_progress_notifications`] to retrieve final
/// progress values without legacy `f64` conversion. Exact 2024-11-05
/// sessions never retain values in either queue; use
/// [`Self::take_legacy_notifications`] for the 2024 receive pump.
#[must_use]
pub fn take_final_server_notifications(&mut self) -> Vec<ServerNotification> {
self.final_server_notifications.drain(..).collect()
}
/// Pops one exact-2024 server notification retained by the stdio receive
/// pump. Modern sessions never retain values here.
#[must_use]
pub fn take_legacy_notification(&mut self) -> Option<JsonRpcRequest> {
self.legacy_server_notifications.pop_front()
}
/// Drains exact-2024 server notifications retained by the stdio receive
/// pump. Modern sessions never retain values here.
#[must_use]
pub fn take_legacy_notifications(&mut self) -> Vec<JsonRpcRequest> {
self.legacy_server_notifications.drain(..).collect()
}
/// Drains exact final progress notifications received during modern requests.
///
/// The returned [`FinalProgressNotificationParams`] preserve the original
/// JSON-number lexemes, so values such as `1e400` remain observable even
/// though the legacy [`ProgressCallback`] accepts only finite `f64` values.
#[must_use]
pub fn take_final_progress_notifications(&mut self) -> Vec<FinalProgressNotificationParams> {
self.final_progress_notifications.drain(..).collect()
}
/// Returns whether final complete-result caching is enabled for this client.
///
/// Exact MCP 2024-11-05 requests bypass this cache unconditionally.
#[must_use]
pub const fn final_result_cache_enabled(&self) -> bool {
self.final_result_cache.is_enabled()
}
/// Enables or disables final complete-result caching for this client.
///
/// Disabling the cache is an opt-out: retained entries remain local and
/// unavailable until caching is enabled again.
pub fn set_final_result_cache_enabled(&mut self, enabled: bool) {
self.final_result_cache.set_enabled(enabled);
}
/// Returns redacted aggregate final-cache counters.
#[must_use]
pub const fn final_result_cache_stats(&self) -> FinalCacheStats {
self.final_result_cache.stats()
}
/// Removes all final complete-result cache entries for this client.
pub fn clear_final_result_cache(&mut self) {
self.final_result_cache.clear();
}
/// Drains bounded compatibility diagnostics for peer cache TTLs that were
/// accepted with zero freshness.
#[must_use]
pub fn take_final_cache_ttl_diagnostics(&mut self) -> Vec<FinalCacheTtlDiagnostic> {
self.final_cache_ttl_diagnostics.drain(..).collect()
}
fn retain_final_cache_ttl_diagnostic(&mut self, diagnostic: FinalCacheTtlDiagnostic) {
if self.final_cache_ttl_diagnostics.len() >= MAX_FINAL_CACHE_TTL_DIAGNOSTICS {
self.final_cache_ttl_diagnostics.pop_front();
}
self.final_cache_ttl_diagnostics.push_back(diagnostic);
}
/// Returns the immutable transport policy and endpoint configuration.
#[must_use]
pub const fn protocol_plan(&self) -> &ClientProtocolPlan {
self.session.protocol_plan()
}
/// Returns the timeout policy applied to subsequent ordinary requests.
#[must_use]
pub const fn request_timeout_policy(&self) -> RequestTimeoutPolicy {
self.timeout_policy
}
/// Replaces the timeout policy applied to subsequent ordinary requests.
///
/// # Errors
///
/// Returns an invalid-parameters error without changing the current policy
/// when either duration is below 1 millisecond or exceeds its hard ceiling.
pub fn set_request_timeout_policy(&mut self, policy: RequestTimeoutPolicy) -> McpResult<()> {
policy.validate()?;
self.timeout_policy = policy;
if let Some(executor) = &self.multiplexed_executor {
executor.set_timeout_policy(policy)?;
}
Ok(())
}
/// Clears reverse request handlers on a live client.
///
/// Non-empty exact-2024 callback handlers must be configured through
/// [`ClientBuilder::reverse_request_handlers`] before initialization so
/// the advertised capabilities and callable methods cannot diverge.
pub fn set_reverse_request_handlers(
&mut self,
handlers: ReverseRequestHandlers,
) -> McpResult<()> {
if !handlers.is_empty() {
return Err(McpError::invalid_params(
"Configure reverse request handlers with ClientBuilder before initialization",
));
}
self.reverse_request_handlers = handlers;
Ok(())
}
pub(crate) fn install_reverse_request_handlers_before_initialization(
&mut self,
handlers: ReverseRequestHandlers,
) {
debug_assert!(!self.initialized.load(Ordering::SeqCst));
self.reverse_request_handlers = handlers;
}
/// Attaches the builder-owned inbound reverse slot so as_proxy can bind
/// the current request context into the pre-initialize handlers.
pub(crate) fn attach_inbound_legacy_reverse_slot(
&mut self,
slot: Arc<Mutex<Option<McpContext>>>,
) {
self.inbound_legacy_reverse = slot;
}
/// Binds the inbound request context for reverse sampling/roots forward.
pub fn bind_inbound_legacy_reverse(&mut self, ctx: &McpContext) -> McpResult<()> {
*self
.inbound_legacy_reverse
.lock()
.map_err(|_| McpError::internal_error("Stdio inbound reverse lock poisoned"))? =
Some(ctx.clone());
Ok(())
}
/// Clears the inbound reverse-forwarding context after one request.
pub fn unbind_inbound_legacy_reverse(&mut self) -> McpResult<()> {
*self
.inbound_legacy_reverse
.lock()
.map_err(|_| McpError::internal_error("Stdio inbound reverse lock poisoned"))? = None;
Ok(())
}
fn server_request_response(&mut self, request: &JsonRpcRequest) -> Option<JsonRpcMessage> {
match live_server_request_dispatch(
self.session.selected_era(),
&self.reverse_request_handlers,
&self.reverse_callback_pool,
request,
)? {
LiveServerRequestDispatch::Immediate(response) => Some(response),
LiveServerRequestDispatch::CallbackAdmitted => None,
}
}
fn cancel_legacy_reverse_callback(&mut self, request: &JsonRpcRequest) -> bool {
if self.session.selected_era() != Some(ProtocolEra::Legacy2024) {
return false;
}
let Ok(CancellationWireMessage::Legacy2024 { params, .. }) =
CancellationWireMessage::decode(
ProtocolEra::Legacy2024,
CancellationSender::Server,
request,
)
else {
return false;
};
self.reverse_callback_pool.cancel(¶ms.request_id)
}
fn drain_completed_reverse_callbacks(&mut self) -> McpResult<()> {
if let Some(error) = self.reverse_callback_pool.state.terminal_error() {
return Err(error);
}
if let Err(error) = self.reverse_callback_pool.reap_finished_tasks() {
self.reverse_callback_pool
.state
.fail_connection(error.clone());
return Err(error);
}
self.reverse_callback_pool
.state
.terminal_error()
.map_or(Ok(()), Err)
}
fn reverse_callback_poll_deadline(&self, deadline: Instant) -> Instant {
deadline.min(
Instant::now()
.checked_add(REVERSE_CALLBACK_POLL_SLICE)
.unwrap_or(deadline),
)
}
fn cancel_reverse_callback_pool(&self) {
self.reverse_callback_pool.cancel_all();
}
fn join_reverse_callback_pool(&mut self) -> McpResult<()> {
self.reverse_callback_pool.join_bounded()
}
fn abort_reverse_callback_pool_for_drop(&self) {
self.reverse_callback_pool.abort_for_drop();
}
fn transport_is_closed(&self) -> bool {
self.transport
.lock()
.map_or(true, |transport| transport.is_closed())
}
fn close_transport(&mut self) -> Result<(), TransportError> {
let receiver = self
.transport
.lock()
.map_err(|_| TransportError::Closed)?
.close();
let sender = self
.response_sender
.lock()
.map_err(|_| TransportError::Closed)?
.close();
receiver.and(sender)
}
fn send_to_server(&self, message: &JsonRpcMessage) -> Result<(), TransportError> {
self.send_to_server_with_cx(&self.cx, message)
}
fn send_to_server_with_cx(
&self,
cx: &Cx,
message: &JsonRpcMessage,
) -> Result<(), TransportError> {
self.response_sender
.lock()
.map_err(|_| TransportError::Closed)?
.send(cx, message)
}
fn activate_selected_io(&mut self) {
if self.selected_io.is_none() && self.session.selected_era().is_some() {
self.selected_io = Some(NegotiatedClientIo::new(
SharedStdioRecv(Arc::clone(&self.transport)),
SharedStdioSend(Arc::clone(&self.response_sender)),
));
}
}
/// Receives one source-preserving frame after selection.
///
/// The fallback is reachable only while the initialization handshake is
/// still provisional. Once an era is selected, every live receive loop
/// consumes the frame through `NegotiatedClientIo`.
fn recv_next_child_frame(
&mut self,
cx: &Cx,
deadline: Option<Instant>,
) -> Result<(ReceivedTransportFrame, Instant), TransportError> {
if let Some(io) = self.selected_io.as_mut() {
io.recv_until_with_source(cx, deadline)
} else {
recv_shared_child_transport(&self.transport, cx, deadline)
}
}
fn install_multiplexed_stdio_executor(&mut self) {
if self.multiplexed_executor.is_some() {
return;
}
let Some(peer_era) = self.session.selected_era() else {
return;
};
let Some(transport) = self.selected_io.clone() else {
return;
};
let modern_request_meta = (peer_era == ProtocolEra::Modern2026).then(|| FinalRequestMeta {
protocol_version: MODERN_PROTOCOL_VERSION.to_owned(),
client_capabilities: self.session.client_capabilities().clone(),
client_info: Some(self.session.modern_client_implementation()),
additional_metadata: BTreeMap::default(),
});
self.multiplexed_executor = Some(StdioRequestExecutor::new(
transport,
Arc::clone(&self.next_id),
peer_era,
self.timeout_policy,
modern_request_meta,
self.session.client_extension_runtime().cloned(),
));
}
/// Returns the negotiated shared stdio executor.
///
/// The ordinary `Client` convenience methods remain sequential adapters.
/// Callers may clone this executor and commit several request-owned
/// handles before the Client drives selected ingress.
pub fn multiplexed_stdio_executor(&self) -> McpResult<StdioRequestExecutor> {
self.multiplexed_executor.clone().ok_or_else(|| {
McpError::invalid_request(
"Negotiated stdio multiplexing is unavailable before initialization",
)
})
}
/// Commits one raw JSON-RPC request through the negotiated shared stdio
/// executor without waiting for its response.
///
/// A Modern2026 session stamps the same `_meta` protocol version and
/// client capabilities the typed verbs already send. Callers still supply
/// the method body; they do not have to reconstruct era admission.
pub fn start_multiplexed_request(
&mut self,
cx: &Cx,
method: impl Into<String>,
params: Option<serde_json::Value>,
) -> McpResult<StdioRequestExecution> {
let executor = self.multiplexed_stdio_executor()?;
executor.service(cx)?;
let params = match params {
Some(params) => Some(self.prepare_request_parameters(params)?),
None if self.session.selected_era() == Some(ProtocolEra::Modern2026) => {
Some(self.prepare_request_parameters(serde_json::json!({}))?)
}
None => None,
};
executor.execute(cx, method, params)
}
/// Starts one exact-2024 stdio request without waiting so the caller can
/// yield while reverse `sampling/createMessage` / `roots/list` run on the
/// inbound serve runtime.
///
/// The sequential `request_core_with_cancellation` path parks the current
/// thread in `recv`. That freezes both the reverse-callback tasks (spawned
/// on this runtime) and any inbound HTTP POST that must deliver their
/// results. This start + `drive_yielding_stdio_slice` +
/// `try_take_yielding_stdio_response` loop is the yielding counterpart.
pub fn start_yielding_stdio_request(
&mut self,
method: impl Into<String>,
params: Option<serde_json::Value>,
) -> McpResult<StdioRequestExecution> {
self.ensure_initialized()?;
self.install_multiplexed_stdio_executor();
let connection_cx = self.cx.clone();
self.start_multiplexed_request(&connection_cx, method, params)
}
/// Takes one already-routed stdio response without reading the transport.
pub fn try_take_yielding_stdio_response(
&self,
execution: &mut StdioRequestExecution,
) -> McpResult<Option<(JsonRpcResponse, Option<String>)>> {
let executor = self.multiplexed_stdio_executor()?;
executor.try_take_response_with_raw_result(execution)
}
/// Drives one bounded stdio ingress turn so reverse callbacks can be
/// admitted, then returns so the caller can yield the inbound runtime.
pub fn drive_yielding_stdio_slice(&mut self) -> McpResult<()> {
let connection_cx = self.cx.clone();
let receive_deadline = Instant::now()
.checked_add(REVERSE_CALLBACK_POLL_SLICE)
.unwrap_or_else(Instant::now);
self.drive_multiplexed_stdio_until(&connection_cx, Some(receive_deadline))
}
/// Cancels one yielding stdio request through the connection-owned executor.
pub fn cancel_yielding_stdio_request(
&mut self,
execution: &mut StdioRequestExecution,
) -> McpResult<()> {
let executor = self.multiplexed_stdio_executor()?;
let connection_cx = self.cx.clone();
executor
.cancel(&connection_cx, execution)
.and_then(|()| executor.service(&connection_cx))
.map_err(|error| self.terminate_connection(error))
}
/// Waits for one multiplexed request through the same sole ingress path
/// used by the sequential convenience API. Server requests, cancellation
/// controls, notifications, and every response ID therefore retain their
/// normal routing semantics instead of being discarded by a parallel
/// reader.
///
/// This synchronous form blocks a `RuntimeBuilder::current_thread()`
/// worker while it waits, so it cannot schedule an exact-2024 reverse
/// callback on that worker. On Unix, use `Client::request_with_cx` for
/// that case.
pub fn wait_multiplexed_request(
&mut self,
cx: &Cx,
execution: &mut StdioRequestExecution,
) -> McpResult<JsonRpcResponse> {
let executor = self.multiplexed_stdio_executor()?;
loop {
if let Some(response) = executor.try_take_response(execution)? {
return Ok(response);
}
self.drive_multiplexed_stdio(cx)?;
}
}
/// Commits and drives one raw request through the negotiated stdio
/// connection using the caller's asupersync context.
///
/// This remains the connection's only ingress driver. Each turn performs
/// at most one bounded 10 ms native stdio readiness wait, checkpoints the
/// supplied context, and then yields to asupersync before another turn.
/// That lets exact-2024 reverse callback tasks run on a valid
/// single-worker `RuntimeBuilder::current_thread()`
/// runtime without creating a runtime or helper thread. It is cooperative
/// polling rather than an async child-pipe receive primitive.
///
/// The supplied context owns only this operation. It is checked before
/// and after each connection-owned receive turn; cancellation explicitly
/// selects and services this execution with the retained connection
/// context before returning. Connection-context cancellation, framing,
/// and transport failures retain their existing terminal connection
/// behavior.
#[cfg(unix)]
pub async fn request_with_cx(
&mut self,
cx: &Cx,
method: impl Into<String>,
params: Option<serde_json::Value>,
) -> McpResult<JsonRpcResponse> {
self.request_with_cx_and_raw_result(cx, method, params)
.await
.map(|(response, _raw_result)| response)
}
#[cfg(unix)]
async fn request_with_cx_and_raw_result(
&mut self,
cx: &Cx,
method: impl Into<String>,
params: Option<serde_json::Value>,
) -> McpResult<(JsonRpcResponse, Option<String>)> {
let executor = self.multiplexed_stdio_executor()?;
let connection_cx = self.cx.clone();
if cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
let mut execution = self.start_multiplexed_request(&connection_cx, method, params)?;
if cx.checkpoint().is_err() {
self.cancel_multiplexed_request_with_connection_cx(
&connection_cx,
&executor,
&mut execution,
)?;
return Err(McpError::request_cancelled());
}
loop {
if let Some(response) = executor.try_take_response_with_raw_result(&mut execution)? {
return Ok(response);
}
if cx.checkpoint().is_err() {
self.cancel_multiplexed_request_with_connection_cx(
&connection_cx,
&executor,
&mut execution,
)?;
return Err(McpError::request_cancelled());
}
let receive_deadline = Instant::now()
.checked_add(REVERSE_CALLBACK_POLL_SLICE)
.unwrap_or_else(Instant::now);
self.drive_multiplexed_stdio_until(&connection_cx, Some(receive_deadline))?;
if cx.checkpoint().is_err() {
self.cancel_multiplexed_request_with_connection_cx(
&connection_cx,
&executor,
&mut execution,
)?;
return Err(McpError::request_cancelled());
}
asupersync::runtime::yield_now().await;
}
}
/// Sends one exact-2024 typed core request while cooperatively driving the
/// sole stdio ingress owner on Unix.
///
/// Unlike the raw request surface, the input and output are permanently
/// confined to the legacy core vocabulary. Method-specific request and
/// result validation therefore remains identical to the synchronous typed
/// client API, including rejection of final result discriminators and
/// metadata. The operation context can cancel this request without
/// cancelling the retained connection context.
#[cfg(all(unix, feature = "legacy-2024-11-05"))]
pub async fn request_legacy_core_with_cx(
&mut self,
cx: &Cx,
request: LegacyCoreRequest,
) -> McpResult<LegacyCoreResult> {
self.require_legacy_exact_result_session(request.method())?;
let request = CoreRequest::Legacy(request);
let method = request.method();
let params = request.encode_params().map_err(|error| {
McpError::invalid_params(format!(
"Legacy client request parameters are invalid: {error}"
))
})?;
let (response, raw_result) = self
.request_with_cx_and_raw_result(cx, method, params)
.await?;
let raw_result = raw_result.as_deref().ok_or_else(|| {
self.terminate_connection(McpError::invalid_request(
"Exact-2024 typed response lost its admitted result source",
))
})?;
match request
.decode_response_result(&response, raw_result)
.map_err(|error| {
self.terminate_connection(McpError::invalid_request(format!(
"Invalid exact-2024 core response: {error}"
)))
})? {
CoreResult::Legacy(result) => Ok(result),
CoreResult::Final(_) => Err(self.terminate_connection(McpError::internal_error(
"Exact-2024 typed request received a final result",
))),
}
}
#[cfg(unix)]
fn cancel_multiplexed_request_with_connection_cx(
&mut self,
connection_cx: &Cx,
executor: &StdioRequestExecutor,
execution: &mut StdioRequestExecution,
) -> McpResult<()> {
executor
.cancel(connection_cx, execution)
.and_then(|()| executor.service(connection_cx))
.map_err(|error| self.terminate_connection(error))
}
/// Admits and routes one selected stdio frame for every request-owned
/// handle on this connection.
///
/// This is the only public ingress driver for cloned
/// [`StdioRequestExecutor`] handles. It services dropped owners before a
/// read, routes matching final/progress frames to the executor through
/// `drive_frame`, and leaves reverse requests plus unrelated Client
/// traffic on their existing connection-owned paths.
pub fn drive_multiplexed_stdio(&mut self, cx: &Cx) -> McpResult<()> {
self.drive_multiplexed_stdio_until(cx, None)
}
/// Services one shared stdio ingress turn, optionally bounding the native
/// receive wait. Apps forwarding uses the bounded form so its View bridge
/// can admit request-local cancellation between upstream receive turns.
fn drive_multiplexed_stdio_until(
&mut self,
cx: &Cx,
receive_deadline: Option<Instant>,
) -> McpResult<()> {
if let Err(error) = self.drain_completed_reverse_callbacks() {
return Err(self.terminate_connection(error));
}
let executor = self.multiplexed_stdio_executor()?;
executor
.service(cx)
.map_err(|error| self.terminate_connection(error))?;
let deadline = match (executor.next_pending_deadline(), receive_deadline) {
(Some(request_deadline), Some(receive_deadline)) => {
Some(request_deadline.min(receive_deadline))
}
(Some(deadline), None) | (None, Some(deadline)) => Some(deadline),
(None, None) => None,
};
let (frame, received_at) = match self.recv_next_child_frame(cx, deadline) {
Ok(frame) => frame,
Err(TransportError::ReceiveDeadlineExceeded) if !self.transport_is_closed() => {
executor
.service(cx)
.map_err(|error| self.terminate_connection(error))?;
if let Err(error) = self.drain_completed_reverse_callbacks() {
return Err(self.terminate_connection(error));
}
return Ok(());
}
Err(error) => return Err(self.terminate_connection(transport_error_to_mcp(error))),
};
executor
.service_at(cx, received_at)
.map_err(|error| self.terminate_connection(error))?;
self.process_selected_ingress_frame(cx, frame)?;
if let Err(error) = self.drain_completed_reverse_callbacks() {
return Err(self.terminate_connection(error));
}
Ok(())
}
/// Flushes request-owner terminal transitions without taking another
/// stdio receive turn. This is the cancellation commit path for a dropped
/// Apps forwarding handle.
#[cfg(feature = "apps")]
pub(crate) fn service_multiplexed_stdio(&mut self, cx: &Cx) -> McpResult<()> {
let executor = self.multiplexed_stdio_executor()?;
executor
.service(cx)
.map_err(|error| self.terminate_connection(error))
}
/// Verifies that the initialized server can answer an MCP ping request.
///
/// A modern session stamps the same `_meta` protocol version the typed
/// verbs send. Exact-2024 keeps the empty-object ping body.
///
/// # Errors
///
/// Returns an error when initialization, transport, envelope validation,
/// or the server's ping response fails.
pub fn ping(&mut self) -> McpResult<()> {
self.ensure_initialized()?;
let params = self.prepare_request_parameters(serde_json::json!({}))?;
let _: serde_json::Value = self.send_prepared_request("ping", params)?.result;
Ok(())
}
/// Sends `ping` under a request-local cancellation domain.
///
/// A cancellation observed before send makes no transport contact.
pub fn ping_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
) -> McpResult<()> {
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
self.ensure_initialized()?;
let params = self.prepare_request_parameters(serde_json::json!({}))?;
let _: serde_json::Value = self
.send_prepared_request_with_request_cancellation(
cx,
cancellation,
"ping",
params,
RequestCancellationTerminalElection::CancelFirst,
|_| {},
)?
.result;
Ok(())
}
/// Generates the next request ID.
fn next_request_id(&self) -> McpResult<u64> {
next_stdio_request_id(&self.next_id)
}
fn with_modern_request_metadata(
&self,
mut params: serde_json::Value,
) -> McpResult<serde_json::Value> {
let parameters = params.as_object_mut().ok_or_else(|| {
McpError::invalid_params("Modern MCP requests require object parameters")
})?;
let metadata = parameters
.entry("_meta")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
let metadata = metadata.as_object_mut().ok_or_else(|| {
McpError::invalid_params("Modern MCP request metadata must be an object")
})?;
let final_metadata = FinalRequestMeta {
protocol_version: MODERN_PROTOCOL_VERSION.to_owned(),
client_capabilities: self.session.client_capabilities().clone(),
client_info: Some(self.session.modern_client_implementation()),
additional_metadata: BTreeMap::default(),
};
let final_metadata = serde_json::to_value(final_metadata).map_err(|error| {
McpError::internal_error(format!(
"Failed to serialize modern request metadata: {error}"
))
})?;
let final_metadata = final_metadata.as_object().ok_or_else(|| {
McpError::internal_error("Modern request metadata did not serialize as an object")
})?;
let mut final_metadata = final_metadata.clone();
if let Some(configured_extensions) = self.session.client_extension_wire_settings() {
let capabilities = final_metadata
.get_mut(FINAL_CLIENT_CAPABILITIES_META_KEY)
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
McpError::internal_error("Modern request metadata omitted client capabilities")
})?;
let extensions = capabilities
.entry("extensions")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
.as_object_mut()
.ok_or_else(|| {
McpError::internal_error("Modern client extensions must be an object")
})?;
for (extension_id, settings) in configured_extensions {
extensions.insert(extension_id, settings);
}
}
#[cfg(feature = "tasks")]
insert_negotiated_tasks_client_extension(
&mut final_metadata,
self.session.server_discovery(),
)?;
#[cfg(feature = "apps")]
let advertise_mcp_apps = !self.session.generic_mcp_apps_configured()
&& (self.session.server_discovery().is_none() || self.session.mcp_apps_active());
#[cfg(not(feature = "apps"))]
let advertise_mcp_apps = false;
if let Some(settings) = advertise_mcp_apps
.then_some(self.session.mcp_apps_settings())
.flatten()
{
let capabilities = final_metadata
.get_mut(FINAL_CLIENT_CAPABILITIES_META_KEY)
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
McpError::internal_error("Modern request metadata omitted client capabilities")
})?;
let extensions = capabilities
.entry("extensions")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
.as_object_mut()
.ok_or_else(|| {
McpError::internal_error("Modern client extensions must be an object")
})?;
extensions.insert(
fastmcp_protocol::extensions::OFFICIAL_MCP_APPS_EXTENSION_ID.to_owned(),
settings.to_extension_settings().into_value(),
);
}
let inbound_client_info = metadata
.get(FINAL_CLIENT_INFO_META_KEY)
.cloned()
.or_else(|| metadata.get("clientInfo").cloned());
let inbound_log_level = metadata.get(FINAL_LOG_LEVEL_META_KEY).cloned();
let inbound_capabilities = metadata.get(FINAL_CLIENT_CAPABILITIES_META_KEY).cloned();
metadata.extend(final_metadata);
if let Some(inbound_client_info) = inbound_client_info {
metadata.insert(FINAL_CLIENT_INFO_META_KEY.to_owned(), inbound_client_info);
}
insert_final_request_log_level(metadata, self.final_log_level)?;
if let Some(inbound_log_level) = inbound_log_level {
metadata.insert(FINAL_LOG_LEVEL_META_KEY.to_owned(), inbound_log_level);
}
if let Some(inbound) = inbound_capabilities.and_then(|value| value.as_object().cloned()) {
if let Some(capabilities) = metadata
.get_mut(FINAL_CLIENT_CAPABILITIES_META_KEY)
.and_then(serde_json::Value::as_object_mut)
{
for key in ["sampling", "elicitation", "roots"] {
match inbound.get(key) {
Some(value) => {
capabilities.insert(key.to_owned(), value.clone());
}
None => {
capabilities.remove(key);
}
}
}
}
}
Ok(params)
}
/// Builds the one selected-era JSON-RPC cancellation notification.
///
/// Cancellation notifications contain only their cancellation parameters.
/// Optional final notification metadata is never required or synthesized.
fn cancellation_control_message(
&self,
request_id: RequestId,
reason: Option<String>,
) -> McpResult<JsonRpcMessage> {
let cancellation = match self.session.selected_era() {
Some(ProtocolEra::Legacy2024) => CancellationWireMessage::Legacy2024 {
sender: CancellationSender::Client,
params: CancelledParams { request_id, reason },
},
Some(ProtocolEra::Modern2026) => CancellationWireMessage::Modern2026 {
sender: CancellationSender::Client,
params: FinalCancelledNotificationParams {
request_id,
reason,
meta: None,
additional: BTreeMap::default(),
},
},
None => {
return Err(McpError::internal_error(
"Client has no negotiated protocol era for cancellation",
));
}
};
cancellation
.encode()
.map(JsonRpcMessage::Request)
.map_err(|error| {
McpError::invalid_params(format!(
"Invalid cancellation control parameters: {error}"
))
})
}
fn prepare_request_parameters(
&self,
params: serde_json::Value,
) -> McpResult<serde_json::Value> {
if self.session.selected_era() == Some(ProtocolEra::Modern2026) {
self.with_modern_request_metadata(params)
} else {
Ok(params)
}
}
/// Builds the exact final `_meta` object required by a Tasks request.
///
/// Tasks has no legacy projection. The shared modern metadata builder
/// supplies the negotiated protocol version, caller capabilities, client
/// identity, and any selected final logging preference without broadening
/// the Tasks parameter shape.
#[cfg(feature = "tasks")]
fn final_task_request_meta(&self) -> McpResult<TaskRequestMeta> {
let params = self.with_final_tasks_client_capability(serde_json::json!({}))?;
let metadata = params
.get("_meta")
.cloned()
.ok_or_else(|| McpError::internal_error("Modern Tasks request metadata was omitted"))?;
let meta = serde_json::from_value(metadata).map_err(|error| {
McpError::internal_error(format!(
"Modern Tasks request metadata did not retain its final shape: {error}"
))
})?;
Ok(TaskRequestMeta { meta })
}
#[cfg(feature = "tasks")]
fn with_final_tasks_client_capability(
&self,
params: serde_json::Value,
) -> McpResult<serde_json::Value> {
let mut params = self.with_modern_request_metadata(params)?;
let metadata = params
.get_mut("_meta")
.ok_or_else(|| McpError::internal_error("Modern Tasks request metadata was omitted"))?;
let capabilities = metadata
.as_object_mut()
.and_then(|metadata| metadata.get_mut(FINAL_CLIENT_CAPABILITIES_META_KEY))
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
McpError::internal_error(
"Modern Tasks request metadata omitted final client capabilities",
)
})?;
let extensions = capabilities
.entry("extensions")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
.as_object_mut()
.ok_or_else(|| {
McpError::internal_error("Modern Tasks client extensions must be an object")
})?;
extensions.insert(
fastmcp_protocol::TASKS_EXTENSION.to_owned(),
serde_json::json!({}),
);
Ok(params)
}
/// Admits one official final Tasks method through bilateral empty-settings
/// negotiation before allocating a request ID or writing to the peer.
#[cfg(feature = "tasks")]
fn admit_final_tasks_method(&mut self, method: &str) -> McpResult<()> {
self.admit_final_tasks_direction(method, ExtensionDirection::ClientToServer)
}
#[cfg(feature = "tasks")]
fn admit_final_tasks_direction(
&mut self,
method: &str,
direction: ExtensionDirection,
) -> McpResult<()> {
self.ensure_initialized()?;
if self.session.selected_era() != Some(ProtocolEra::Modern2026) {
return Err(McpError::invalid_params(
"io.modelcontextprotocol/tasks is unavailable in exact MCP 2024-11-05",
));
}
let discovery = self.server_discovery().ok_or_else(|| {
McpError::invalid_params(
"Modern Tasks requires the retained final server/discover response",
)
})?;
admit_final_tasks_discovery_surface(discovery, method, direction)
}
/// Sends one raw final extension request after exact bilateral admission.
///
/// The builder-owned frozen descriptor registry and the extension set
/// retained from `server/discover` must both authorize `extension_id` and
/// `method` as a client-to-server request. Rejection happens before a
/// request ID is allocated, a response waiter is registered, or bytes are
/// committed to the peer. The returned [`serde_json::Value`] is decoded
/// from the existing source-preserving correlated result path.
pub fn request_final_extension(
&mut self,
extension_id: &fastmcp_protocol::ExtensionId,
method: &str,
parameters: serde_json::Value,
) -> McpResult<serde_json::Value> {
self.ensure_initialized()?;
self.session
.admit_final_extension_method(extension_id, method)?;
let parameters = self.prepare_request_parameters(parameters)?;
let received = self.send_prepared_request(method, parameters)?;
let source = received.raw_result.as_deref().ok_or_else(|| {
self.terminate_connection(McpError::invalid_request(
"Peer final extension response lost its admitted result source",
))
})?;
serde_json::from_str(source).map_err(|_| {
self.terminate_connection(McpError::invalid_request(
"Peer response result is not valid JSON for the admitted final extension request",
))
})
}
/// Sends one already-admitted final Tasks request and decodes its exact
/// result envelope. A malformed task response is a peer protocol
/// contradiction and terminates this connection.
#[cfg(feature = "tasks")]
fn send_final_task_request<P, R>(&mut self, method: &str, params: P) -> McpResult<R>
where
P: serde::Serialize,
R: serde::de::DeserializeOwned,
{
let params = serde_json::to_value(params).map_err(|error| {
McpError::internal_error(format!("Failed to serialize final Tasks request: {error}"))
})?;
let result = self.send_prepared_request(method, params)?;
let result_source = result.raw_result.as_deref().ok_or_else(|| {
self.terminate_connection(McpError::invalid_request(
"Peer final Tasks response lost its admitted result source",
))
})?;
serde_json::from_str(result_source).map_err(|_| {
self.terminate_connection(McpError::invalid_request(
"Peer response does not match the admitted final Tasks result",
))
})
}
/// Sends one already-admitted final Tasks request under a request-local
/// cancellation domain.
#[cfg(feature = "tasks")]
fn send_final_task_request_with_cancellation<P, R>(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
method: &str,
params: P,
) -> McpResult<R>
where
P: serde::Serialize,
R: serde::de::DeserializeOwned,
{
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
let params = serde_json::to_value(params).map_err(|error| {
McpError::internal_error(format!("Failed to serialize final Tasks request: {error}"))
})?;
let result = self.send_prepared_request_with_request_cancellation(
cx,
cancellation,
method,
params,
RequestCancellationTerminalElection::CancelFirst,
|_| {},
)?;
let result_source = result.raw_result.as_deref().ok_or_else(|| {
self.terminate_connection(McpError::invalid_request(
"Peer final Tasks response lost its admitted result source",
))
})?;
serde_json::from_str(result_source).map_err(|_| {
self.terminate_connection(McpError::invalid_request(
"Peer response does not match the admitted final Tasks result",
))
})
}
/// Decodes a prepared supported-core request in the immutable selected era.
///
/// Non-core methods continue through the ordinary response path. A core
/// request with invalid selected-era parameters is rejected before any
/// request ID is allocated or bytes are committed to the peer.
fn prepared_core_request(
&self,
method: &str,
params: &serde_json::Value,
) -> McpResult<Option<CoreRequest>> {
let Some(era) = self.session.selected_era() else {
return Ok(None);
};
match CoreRequest::decode(era, method, Some(params)) {
Ok(request) => Ok(Some(request)),
Err(CoreDispatchError::UnsupportedMethod { .. }) => Ok(None),
Err(_) => Err(McpError::invalid_params(
"Client core request parameters do not match the negotiated protocol era",
)),
}
}
fn retain_modern_server_notification(
&mut self,
frame: &ReceivedTransportFrame,
) -> McpResult<Option<ModernServerNotification>> {
let JsonRpcMessage::Request(request) = frame.message() else {
return Err(McpError::internal_error(
"Client final notification retention received a response frame",
));
};
let modern_session = match self.session.selected_era() {
Some(ProtocolEra::Modern2026) => true,
Some(ProtocolEra::Legacy2024) => false,
None => self.session.protocol_plan().policy() == ProtocolPolicy::ModernOnly,
};
if !modern_session || !is_final_server_notification_method(request) {
return Ok(None);
}
if request.method == "notifications/cancelled" {
let Ok(cancellation) = CancellationWireMessage::decode(
ProtocolEra::Modern2026,
CancellationSender::Server,
request,
) else {
return Ok(Some(ModernServerNotification::Retained));
};
if !matches!(cancellation, CancellationWireMessage::Modern2026 { .. }) {
return Ok(Some(ModernServerNotification::Retained));
}
// Generic high-level receive paths have no active
// subscriptions/listen ownership context. A server cancellation is
// therefore retained inertly here; the dedicated stdio listener
// validates and applies cancellation only to its own live stream.
return Ok(Some(ModernServerNotification::Retained));
}
let raw_params = raw_notification_params_from_frame(frame.source())?;
let notification = decode_final_server_notification(request, raw_params.as_deref())
.map_err(|error| {
McpError::invalid_request(format!("Invalid final server notification: {error}"))
})?;
// Advance the matching generation before exposing the notification or
// accepting a late fetch completion. A fetch captures its generation
// before send and can only fill while it remains current.
self.final_result_cache
.invalidate_notification(¬ification);
let ServerNotification::Progress(progress) = notification else {
if self.final_server_notifications.len() >= MAX_QUEUED_FINAL_SERVER_NOTIFICATIONS {
return Err(McpError::invalid_request(
FINAL_SERVER_NOTIFICATION_QUEUE_OVERFLOW_ERROR,
));
}
let log_message = match ¬ification {
ServerNotification::Message(message) => {
Some(final_log_message_sink_projection(message))
}
_ => None,
};
self.final_server_notifications.push_back(notification);
if let Some(message) = log_message {
self.emit_log_message(message);
}
return Ok(Some(ModernServerNotification::Retained));
};
if self.final_progress_notifications.len() >= MAX_QUEUED_FINAL_SERVER_NOTIFICATIONS {
return Err(McpError::invalid_request(
FINAL_SERVER_NOTIFICATION_QUEUE_OVERFLOW_ERROR,
));
}
self.final_progress_notifications
.push_back(progress.clone());
Ok(Some(ModernServerNotification::Progress(Box::new(progress))))
}
fn retain_legacy_server_notification(&mut self, request: &JsonRpcRequest) -> McpResult<bool> {
if self.session.selected_era() != Some(ProtocolEra::Legacy2024) {
return Ok(false);
}
if !is_legacy_server_notification_method(request) {
return Ok(false);
}
if self.legacy_server_notifications.len() >= MAX_QUEUED_FINAL_SERVER_NOTIFICATIONS {
return Err(McpError::invalid_request(
LEGACY_SERVER_NOTIFICATION_QUEUE_OVERFLOW_ERROR,
));
}
if request.method == NOTIFICATIONS_MESSAGE
&& let Some(params) = request.params.as_ref()
&& let Ok(message) = serde_json::from_value::<LogMessageParams>(params.clone())
{
self.emit_log_message(message);
}
self.legacy_server_notifications.push_back(request.clone());
Ok(true)
}
/// Sends a request and waits for response.
fn send_request<P: serde::Serialize, R: serde::de::DeserializeOwned>(
&mut self,
method: &str,
params: P,
) -> McpResult<R> {
// Validate configuration before consuming an ID, registering a waiter,
// or committing any bytes to the peer.
let timeout_policy = self.timeout_policy;
timeout_policy.validate()?;
let params_value = serde_json::to_value(params)
.map_err(|e| McpError::internal_error(format!("Failed to serialize params: {e}")))?;
let params_value = self.prepare_request_parameters(params_value)?;
let core_request = self.prepared_core_request(method, ¶ms_value)?;
let received = self.send_prepared_request(method, params_value)?;
if let Some(core_request) = core_request
&& let Err(error) = decode_core_result_from_source(
&core_request,
&received.result,
received.raw_result.as_deref(),
)
{
return Err(self.terminate_connection(error));
}
decode_response_payload(received.result)
}
/// Sends an already-prepared request and returns its raw result value.
///
/// Callers that need an era-aware result must decode this value with the
/// request that selected its method-specific response contract.
fn send_prepared_request(
&mut self,
method: &str,
params_value: serde_json::Value,
) -> McpResult<ReceivedPreparedResult> {
let cx = self.cx.clone();
self.send_prepared_request_with_cx(&cx, None, method, params_value)
}
/// Sends one selected-era core request under a request-local cancellation
/// domain.
///
/// `on_committed` receives the client-owned JSON-RPC ID immediately after
/// the request frame reaches the upstream transport and before response
/// waiting begins. If cancellation is already observable before commit,
/// the request is removed locally without sending a frame. Once committed,
/// cancellation sends this client's one protocol-correct
/// `notifications/cancelled` control and retires the correlated response.
pub fn request_core_with_cancellation<F>(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
method: &str,
params_value: serde_json::Value,
on_committed: F,
) -> McpResult<CoreResult>
where
F: FnOnce(&RequestId),
{
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
self.ensure_initialized()?;
let params_value = self.prepare_request_parameters(params_value)?;
let core_request = self
.prepared_core_request(method, ¶ms_value)?
.ok_or_else(|| {
McpError::invalid_params(
"Method is not a supported core request in the negotiated era",
)
})?;
let params_value = core_request
.encode_params()
.map_err(|_| {
McpError::invalid_params(
"Client core request could not be encoded in the negotiated protocol era",
)
})?
.ok_or_else(|| {
McpError::invalid_params(
"Method has no parameter object in the negotiated protocol era",
)
})?;
self.last_core_result_receipt = None;
let received = self.send_prepared_request_with_request_cancellation(
cx,
cancellation,
method,
params_value,
RequestCancellationTerminalElection::CancelFirst,
on_committed,
)?;
let (result, ttl_diagnostic) = decode_core_result_with_cache_ttl_from_source(
&core_request,
&received.result,
received.raw_result.as_deref(),
)
.map_err(|error| self.terminate_connection(error))?;
self.last_core_result_receipt = Some(received.receipt);
if let Some(diagnostic) = ttl_diagnostic {
self.retain_final_cache_ttl_diagnostic(diagnostic);
}
Ok(result)
}
fn send_prepared_request_with_request_cancellation<F>(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
method: &str,
params_value: serde_json::Value,
terminal_election: RequestCancellationTerminalElection,
on_committed: F,
) -> McpResult<ReceivedPreparedResult>
where
F: FnOnce(&RequestId),
{
let timeout_policy = self.timeout_policy;
timeout_policy.validate()?;
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
let id = self.next_request_id()?;
let request_id = RequestId::Number(
i64::try_from(id).expect("request ID allocator enforces the i64 bound"),
);
let request = JsonRpcRequest::new(method, Some(params_value), request_id.clone());
let waiter = self.responses.register(request_id.clone())?;
// This second check covers cancellation which arrived after ID
// reservation but before the transport commit. No tombstone is kept:
// no upstream frame can exist on this branch.
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
let _ = self.responses.abandon_before_commit(&request_id);
return Err(McpError::request_cancelled());
}
if let Err(error) = self.send_to_server_with_cx(cx, &JsonRpcMessage::Request(request)) {
let error = self.record_send_failure(Some(&request_id), error);
return Err(error);
}
on_committed(&request_id);
if cancellation.is_cancel_requested() {
self.cancel_request(request_id, None)?;
return Err(McpError::request_cancelled());
}
let committed_at = Instant::now();
let deadlines = RequestDeadlines::start_at(timeout_policy, committed_at)
.map_err(|error| self.finish_committed_request_locally(&request_id, error))?;
let ReceivedJsonRpcResponse {
mut response,
raw_result,
} = self.recv_response_with_request_cancellation(
cx,
waiter,
deadlines,
cancellation,
terminal_election,
)?;
let receipt = Instant::now();
if let Some(error) = response.error.take() {
return Err(json_rpc_error_to_mcp(error));
}
let result = response
.result
.take()
.ok_or_else(|| McpError::internal_error("No result in response"))?;
Ok(ReceivedPreparedResult {
result,
raw_result,
receipt,
})
}
/// Sends one request through the shared stdio executor while a live
/// catalog or Tasks listener already owns that executor.
///
/// Sequential response-registry waits must not mix with an executor-owned
/// listen: the listen is the selected ingress owner, and a second reader
/// can terminate the connection. I/O stays on the connection `Cx`; the
/// caller `Cx` only checkpoints cancellation.
fn send_prepared_request_through_stdio_executor(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
method: &str,
params_value: serde_json::Value,
) -> McpResult<ReceivedPreparedResult> {
let executor = self.multiplexed_stdio_executor()?;
let connection_cx = self.cx.clone();
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
executor.service(&connection_cx)?;
let mut execution = executor.execute(&connection_cx, method, Some(params_value))?;
loop {
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
executor.cancel(&connection_cx, &mut execution)?;
return Err(McpError::request_cancelled());
}
if let Some((mut response, raw_result)) =
executor.try_take_response_with_raw_result(&mut execution)?
{
let receipt = Instant::now();
if let Some(error) = response.error.take() {
return Err(json_rpc_error_to_mcp(error));
}
let result = response
.result
.take()
.ok_or_else(|| McpError::internal_error("No result in response"))?;
return Ok(ReceivedPreparedResult {
result,
raw_result,
receipt,
});
}
self.drive_multiplexed_stdio(&connection_cx)?;
}
}
/// Sends one prepared request under an optional operation-wide deadline.
///
/// Ordinary public requests retain their existing per-request deadline
/// behavior. Multi-round MRTR passes one immutable operation deadline so
/// no continuation can restart the absolute response-wait budget.
fn send_prepared_request_with_cx(
&mut self,
cx: &Cx,
operation_deadline: Option<Instant>,
method: &str,
params_value: serde_json::Value,
) -> McpResult<ReceivedPreparedResult> {
let timeout_policy = self.timeout_policy;
timeout_policy.validate()?;
if cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
if operation_deadline.is_some_and(|deadline| Instant::now() >= deadline) {
return Err(McpError::internal_error(
"MRTR operation absolute deadline elapsed",
));
}
let id = self.next_request_id()?;
let (request_id, request) = {
let id_i64 = i64::try_from(id).expect("request ID allocator enforces the i64 bound");
(
RequestId::Number(id_i64),
JsonRpcRequest::new(method, Some(params_value), id_i64),
)
};
// Register before the committed send so even an immediate response has
// an exact owner in the shared-channel correlation registry.
let waiter = self.responses.register(request_id.clone())?;
if let Err(error) = self.send_to_server_with_cx(cx, &JsonRpcMessage::Request(request)) {
let error = self.record_send_failure(Some(&request_id), error);
return Err(error);
}
let committed_at = Instant::now();
let mut deadlines = match RequestDeadlines::start_at(timeout_policy, committed_at) {
Ok(deadlines) => deadlines,
Err(error) => {
return Err(self.finish_committed_request_locally(&request_id, error));
}
};
if let Some(operation_deadline) = operation_deadline {
deadlines.cap_absolute_at(operation_deadline);
}
// Receive response with ID validation
let ReceivedJsonRpcResponse {
mut response,
raw_result,
} = self.recv_response_with_cx(cx, waiter, deadlines)?;
let receipt = Instant::now();
// Check for error response
if let Some(error) = response.error.take() {
return Err(json_rpc_error_to_mcp(error));
}
// Parse result
let result = response
.result
.take()
.ok_or_else(|| McpError::internal_error("No result in response"))?;
Ok(ReceivedPreparedResult {
result,
raw_result,
receipt,
})
}
/// Sends Auto's one disposable modern discovery request.
///
/// Unlike the ordinary prepared-request path, this preserves two facts
/// until selection is complete: a correlated MethodNotFound reply and a
/// clean no-ingress receive deadline. Both are lost if converted eagerly
/// into the generic request error vocabulary.
fn send_modern_discovery_probe(
&mut self,
params_value: serde_json::Value,
) -> McpResult<Result<ReceivedPreparedResult, AutoStdioFallbackSignal>> {
let timeout_policy = self.timeout_policy;
timeout_policy.validate()?;
let cx = self.cx.clone();
if cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
let id = self.next_request_id()?;
let request_id = RequestId::Number(
i64::try_from(id).expect("request ID allocator enforces the i64 bound"),
);
let request = JsonRpcRequest::new(
SERVER_DISCOVER_METHOD,
Some(params_value),
request_id.clone(),
);
let waiter = self.responses.register(request_id.clone())?;
if let Err(error) = self.send_to_server_with_cx(&cx, &JsonRpcMessage::Request(request)) {
return Err(self.record_send_failure(Some(&request_id), error));
}
let deadlines = RequestDeadlines::start_at(timeout_policy, Instant::now())
.map_err(|error| self.finish_committed_request_locally(&request_id, error))?;
let received = match self.recv_modern_discovery_probe_response(&cx, waiter, deadlines)? {
Ok(received) => received,
Err(signal) => return Ok(Err(signal)),
};
let receipt = Instant::now();
let mut response = received.response;
if let Some(error) = response.error.take() {
if error.code.as_i32() == Some(-32_601) {
return Ok(Err(
AutoStdioFallbackSignal::CorrelatedDiscoverMethodNotFound,
));
}
return Err(json_rpc_error_to_mcp(error));
}
let result = response
.result
.take()
.ok_or_else(|| McpError::internal_error("No result in response"))?;
Ok(Ok(ReceivedPreparedResult {
result,
raw_result: received.raw_result,
receipt,
}))
}
/// Receives the first modern probe response without treating all timeout
/// shapes as equivalent. A timeout is eligible only before any complete
/// inbound frame has been admitted; malformed, wrong-ID, partial, late,
/// transport, and cancellation outcomes all stay terminal.
fn recv_modern_discovery_probe_response(
&mut self,
cx: &Cx,
mut waiter: ResponseWaiter,
deadlines: RequestDeadlines,
) -> McpResult<Result<ReceivedJsonRpcResponse, AutoStdioFallbackSignal>> {
let expected_id = waiter.id.clone();
#[cfg(unix)]
let mut admitted_ingress = false;
loop {
if let Some(response) = waiter.try_response()? {
debug_assert!(
response
.id
.as_ref()
.is_some_and(|response_id| response_id.correlates_with(&expected_id))
);
return Ok(Ok(response));
}
if cx.checkpoint().is_err() {
return Err(self.finish_open_context_interruption(
&expected_id,
McpError::request_cancelled(),
));
}
if let Some(source) = deadlines.expired_at(Instant::now()) {
// Only Unix child pipes expose the readiness boundary that
// distinguishes silence from a consumed partial frame. Other
// targets retain the ordinary terminal timeout rather than
// claiming a clean fallback signal from a blocking read.
#[cfg(unix)]
if !admitted_ingress {
return Ok(Err(AutoStdioFallbackSignal::CleanFirstProbeTimeout {
source,
}));
}
return Err(self.timeout_committed_request(&expected_id, source));
}
let (frame, received_at) = match self.recv_next_child_frame(cx, Some(deadlines.next()))
{
Ok(received) => received,
Err(TransportError::ReceiveDeadlineExceeded) => {
let source = deadlines
.expired_at(Instant::now())
.unwrap_or_else(|| deadlines.next_kind());
if cx.checkpoint().is_err() {
return Err(self.finish_open_context_interruption(
&expected_id,
McpError::request_cancelled(),
));
}
if self.transport_is_closed() {
return Err(self.finish_partial_frame_timeout(&expected_id, source));
}
#[cfg(unix)]
if !admitted_ingress {
return Ok(Err(AutoStdioFallbackSignal::CleanFirstProbeTimeout {
source,
}));
}
return Err(self.timeout_committed_request(&expected_id, source));
}
Err(TransportError::Timeout) if !self.transport_is_closed() => {
return Err(self.finish_open_context_interruption(
&expected_id,
McpError::internal_error("Request timed out"),
));
}
Err(TransportError::Cancelled) if !self.transport_is_closed() => {
return Err(self.finish_open_context_interruption(
&expected_id,
McpError::request_cancelled(),
));
}
Err(error) => return Err(self.terminate_connection(transport_error_to_mcp(error))),
};
#[cfg(unix)]
{
admitted_ingress = true;
}
if let Some(source) = deadlines.expired_at(received_at) {
return Err(self.finish_timeout_after_complete_frame(&expected_id, frame, source));
}
if let Err(error) = validate_inbound_typed_message(frame.message()) {
return Err(self.terminate_connection(error));
}
if matches!(frame.message(), JsonRpcMessage::Response(_)) {
let route = self
.route_received_response(frame)
.map_err(|error| self.terminate_connection(error))?;
if matches!(
route,
ResponseRoute::InvalidEnvelope
| ResponseRoute::MissingId
| ResponseRoute::ConnectionClosed
) {
let error = self.responses.terminal_error().unwrap_or_else(|| {
McpError::internal_error("Client response correlation failed")
});
return Err(self.terminate_connection(error));
}
continue;
}
let JsonRpcMessage::Request(request) = frame.message() else {
unreachable!("a JSON-RPC message is either a request or response")
};
if self.cancel_legacy_reverse_callback(request) {
continue;
}
match self.retain_modern_server_notification(&frame) {
Ok(Some(_)) => continue,
Ok(None) => {}
Err(error) => return Err(self.terminate_connection(error)),
}
if let JsonRpcMessage::Request(request) = frame.message() {
match self.retain_legacy_server_notification(request) {
Ok(true) => continue,
Ok(false) => {}
Err(error) => return Err(self.terminate_connection(error)),
}
}
let JsonRpcMessage::Request(request) = frame.into_message() else {
unreachable!("the frame was checked as a JSON-RPC request")
};
if let Some(response) = self.server_request_response(&request) {
if let Err(error) = self.send_server_response_during_receive(response) {
return Err(self.terminate_connection(error));
}
}
}
}
/// Sends one supported core request and retains its selected-era result.
fn send_typed_core_request<P: serde::Serialize>(
&mut self,
method: &str,
params: P,
) -> McpResult<CoreResult> {
self.send_typed_core_request_with_tasks(method, params, false)
}
/// Forwards one admitted Apps standard-reused method through a new
/// client-owned selected-era core request. Apps envelope IDs and transport
/// controls never enter this request path.
#[cfg(feature = "apps")]
pub(crate) async fn forward_mcp_apps_reused_core(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
method: fastmcp_protocol::McpAppsRoutedMethod,
params: Option<serde_json::Value>,
) -> McpResult<serde_json::Value> {
if cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
if !self.mcp_apps_active() {
return Err(McpError::invalid_request(
"MCP Apps reused methods require the current bilateral activation receipt",
));
}
let (core_method, parameters) = match method {
fastmcp_protocol::McpAppsRoutedMethod::ToolsCall => {
let mut params: CallToolParams =
serde_json::from_value(params.ok_or_else(|| {
McpError::invalid_params("Apps tools/call is missing parameters")
})?)
.map_err(|_| {
McpError::invalid_params("Apps tools/call parameters are invalid")
})?;
params.meta = None;
("tools/call", serde_json::to_value(params))
}
fastmcp_protocol::McpAppsRoutedMethod::ResourcesRead => {
let mut params: ReadResourceParams =
serde_json::from_value(params.ok_or_else(|| {
McpError::invalid_params("Apps resources/read is missing parameters")
})?)
.map_err(|_| {
McpError::invalid_params("Apps resources/read parameters are invalid")
})?;
params.meta = None;
("resources/read", serde_json::to_value(params))
}
fastmcp_protocol::McpAppsRoutedMethod::ResourcesList => {
let params: ListResourcesParams =
serde_json::from_value(params.unwrap_or_else(|| serde_json::json!({})))
.map_err(|_| {
McpError::invalid_params("Apps resources/list parameters are invalid")
})?;
("resources/list", serde_json::to_value(params))
}
fastmcp_protocol::McpAppsRoutedMethod::ResourceTemplatesList => {
let params: ListResourceTemplatesParams =
serde_json::from_value(params.unwrap_or_else(|| serde_json::json!({})))
.map_err(|_| {
McpError::invalid_params(
"Apps resources/templates/list parameters are invalid",
)
})?;
("resources/templates/list", serde_json::to_value(params))
}
fastmcp_protocol::McpAppsRoutedMethod::PromptsList => {
let params: ListPromptsParams =
serde_json::from_value(params.unwrap_or_else(|| serde_json::json!({})))
.map_err(|_| {
McpError::invalid_params("Apps prompts/list parameters are invalid")
})?;
("prompts/list", serde_json::to_value(params))
}
_ => {
return Err(McpError::invalid_params(
"Apps method is not a direction-correct standard-reused core request",
));
}
};
let parameters = parameters.map_err(|_| {
McpError::internal_error("Apps bridge parameters could not form a core request")
})?;
if cancellation.is_cancel_requested() {
return Err(McpError::request_cancelled());
}
let parameters = self.prepare_request_parameters(parameters)?;
let core_request = self
.prepared_core_request(core_method, ¶meters)?
.ok_or_else(|| {
McpError::invalid_params(
"Apps method is not a supported core request in the negotiated era",
)
})?;
let parameters = core_request.encode_params().map_err(|_| {
McpError::invalid_params(
"Apps core request could not be encoded in the negotiated protocol era",
)
})?;
let executor = self.multiplexed_stdio_executor()?;
let mut execution = self.start_multiplexed_request(cx, core_method, parameters)?;
loop {
if cancellation.is_cancel_requested() {
self.cancel_request(execution.request_id().clone(), None)?;
return Err(McpError::request_cancelled());
}
if let Some((mut response, raw_result)) =
executor.try_take_response_with_raw_result(&mut execution)?
{
let result = response
.result
.take()
.ok_or_else(|| McpError::internal_error("No result in response"))?;
let (result, diagnostic) = decode_core_result_with_cache_ttl_from_source(
&core_request,
&result,
raw_result.as_deref(),
)
.map_err(|error| self.terminate_connection(error))?;
if let Some(diagnostic) = diagnostic {
self.retain_final_cache_ttl_diagnostic(diagnostic);
}
self.last_core_result_receipt = Some(Instant::now());
return mcp_apps::project_reused_core_result(method, result);
}
// Never let this policy own the stdio receive until the response
// arrives. A bounded ingress turn followed by an async yield lets
// the outer Apps bridge poll its independent View-control receive
// and cancel this exact request-owned execution.
let receive_deadline = Instant::now()
.checked_add(Duration::from_millis(10))
.unwrap_or_else(Instant::now);
self.drive_multiplexed_stdio_until(cx, Some(receive_deadline))?;
asupersync::time::sleep(cx.now(), Duration::from_millis(1)).await;
}
}
fn send_typed_core_request_with_tasks<P: serde::Serialize>(
&mut self,
method: &str,
params: P,
declare_tasks: bool,
) -> McpResult<CoreResult> {
let params_value = serde_json::to_value(params)
.map_err(|e| McpError::internal_error(format!("Failed to serialize params: {e}")))?;
#[cfg(feature = "tasks")]
let params_value = if declare_tasks {
self.with_final_tasks_client_capability(params_value)?
} else {
self.prepare_request_parameters(params_value)?
};
#[cfg(not(feature = "tasks"))]
let params_value = {
let _ = declare_tasks;
self.prepare_request_parameters(params_value)?
};
let core_request = self
.prepared_core_request(method, ¶ms_value)?
.ok_or_else(|| {
McpError::invalid_params(
"Method is not a supported core request in the negotiated era",
)
})?;
let params_value = core_request
.encode_params()
.map_err(|_| {
McpError::invalid_params(
"Client core request could not be encoded in the negotiated protocol era",
)
})?
.ok_or_else(|| {
McpError::invalid_params(
"Method has no parameter object in the negotiated protocol era",
)
})?;
self.last_core_result_receipt = None;
let received = self.send_prepared_request(method, params_value)?;
let (result, ttl_diagnostic) = decode_core_result_with_cache_ttl_from_source(
&core_request,
&received.result,
received.raw_result.as_deref(),
)
.map_err(|error| self.terminate_connection(error))?;
self.last_core_result_receipt = Some(received.receipt);
if let Some(diagnostic) = ttl_diagnostic {
self.retain_final_cache_ttl_diagnostic(diagnostic);
}
Ok(result)
}
/// Sends one ordinary core request under an operation-owned caller context
/// and absolute deadline.
///
/// This is intentionally separate from Tasks requests: MRTR retries carry
/// the exact original core parameters plus only the current continuation
/// fields, and must not synthesize an extension declaration.
fn send_typed_core_request_with_cx_until(
&mut self,
cx: &Cx,
operation_deadline: Instant,
method: &str,
params_value: serde_json::Value,
) -> McpResult<CoreResult> {
let params_value = self.prepare_request_parameters(params_value)?;
let core_request = self
.prepared_core_request(method, ¶ms_value)?
.ok_or_else(|| {
McpError::invalid_params(
"Method is not a supported core request in the negotiated era",
)
})?;
let params_value = core_request
.encode_params()
.map_err(|_| {
McpError::invalid_params(
"Client core request could not be encoded in the negotiated protocol era",
)
})?
.ok_or_else(|| {
McpError::invalid_params(
"Method has no parameter object in the negotiated protocol era",
)
})?;
self.last_core_result_receipt = None;
let received =
self.send_prepared_request_with_cx(cx, Some(operation_deadline), method, params_value)?;
let (result, ttl_diagnostic) = decode_core_result_with_cache_ttl_from_source(
&core_request,
&received.result,
received.raw_result.as_deref(),
)
.map_err(|error| self.terminate_connection(error))?;
self.last_core_result_receipt = Some(received.receipt);
if let Some(diagnostic) = ttl_diagnostic {
self.retain_final_cache_ttl_diagnostic(diagnostic);
}
Ok(result)
}
fn final_cache_key(
&self,
method: &str,
semantic_parameters: serde_json::Value,
cursor: Option<&str>,
result_set: FinalCacheResultSet,
) -> McpResult<FinalCacheKey> {
let normalized_capabilities = serde_json::to_string(self.session.client_capabilities())
.map_err(|_| {
McpError::internal_error("Client capabilities could not form a cache key")
})?;
let extension_settings = serde_json::to_string(&serde_json::json!({
"mcpApps": self.session.mcp_apps_settings().map(|settings| {
settings.to_extension_settings().into_value()
}),
"descriptorRevision": FINAL_CACHE_EXTENSION_REVISION,
}))
.map_err(|_| {
McpError::internal_error("Client extension settings could not form a cache key")
})?;
let semantic_projection = serde_json::to_string(&semantic_parameters).map_err(|_| {
McpError::internal_error("Client semantic parameters could not form a cache key")
})?;
let endpoint_configuration = self
.session
.protocol_plan()
.modern_post_target()
.unwrap_or("stdio")
.to_owned();
// This cache is owned by one client instance, so this fixed local
// partition cannot cross a connection or credential boundary. The
// revision fields are explicit cache-identity inputs and must change
// when their corresponding local policies change.
Ok(FinalCacheKey::new(
endpoint_configuration,
MODERN_PROTOCOL_VERSION,
normalized_capabilities,
extension_settings,
method,
semantic_projection,
cursor.map(ToOwned::to_owned),
FINAL_CACHE_POLICY_REVISION,
FINAL_CACHE_EXTENSION_REVISION,
FINAL_CACHE_REPRESENTATION_POLICY_REVISION,
FINAL_CACHE_LIMITS_POLICY_REVISION,
CachePartitionKey::new("stdio-client-connection"),
result_set,
))
}
fn cached_final_core_request<F>(
&mut self,
method: &str,
semantic_parameters: serde_json::Value,
cursor: Option<&str>,
result_set: FinalCacheResultSet,
fetch: F,
) -> McpResult<CoreResult>
where
F: FnOnce(&mut Self) -> McpResult<CoreResult>,
{
self.last_final_cache_page = None;
if self.session.selected_era() != Some(ProtocolEra::Modern2026) {
return fetch(self);
}
self.drain_final_cache_invalidations()?;
let key = self.final_cache_key(method, semantic_parameters, cursor, result_set)?;
match self.final_result_cache.lookup_page_at(&key, Instant::now()) {
FinalCachePageLookup::Fresh(page) => {
if self.cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
self.last_final_cache_page = Some(FinalCachePageState {
generation: page.generation,
scope: page.scope,
miss: None,
});
Ok(page.result)
}
FinalCachePageLookup::Miss(miss) => {
let generation = self.final_result_cache.begin_fetch(key.result_set());
let result = match fetch(self) {
Ok(result) => result,
Err(error) => {
if cursor.is_some() && error.code == McpErrorCode::InvalidParams {
self.final_result_cache
.invalidate_result_set(key.result_set());
}
return Err(error);
}
};
let scope = final_cache_hints(&result).map(|(_, scope)| scope);
let receipt = self
.last_core_result_receipt
.take()
.unwrap_or_else(Instant::now);
let page_result_set = key.result_set().clone();
let _ = self.final_result_cache.insert_if_current_at(
key,
generation,
result.clone(),
receipt,
);
let invalidated_during_fetch =
generation != self.final_result_cache.begin_fetch(&page_result_set);
let miss = if invalidated_during_fetch {
Some(FinalCacheMiss::Invalidated)
} else {
Some(miss)
};
if let Some(scope) = scope {
self.last_final_cache_page = Some(FinalCachePageState {
generation: self.final_result_cache.begin_fetch(&page_result_set),
scope,
miss,
});
}
Ok(result)
}
}
}
fn drain_final_cache_invalidations(&mut self) -> McpResult<()> {
if self.cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
#[cfg(unix)]
{
let cx = self.cx.clone();
let deadline = Instant::now() + FINAL_CACHE_NOTIFICATION_DRAIN_WINDOW;
loop {
let receive_deadline = self.reverse_callback_poll_deadline(deadline);
let (frame, _) = match self.recv_next_child_frame(&cx, Some(receive_deadline)) {
Ok(received) => received,
Err(TransportError::ReceiveDeadlineExceeded) if !self.transport_is_closed() => {
if receive_deadline < deadline {
self.drain_completed_reverse_callbacks()
.map_err(|error| self.terminate_connection(error))?;
continue;
}
return Ok(());
}
Err(TransportError::Cancelled) if !self.transport_is_closed() => {
return Err(self.terminate_connection(McpError::request_cancelled()));
}
Err(error) => {
return Err(self.terminate_connection(transport_error_to_mcp(error)));
}
};
self.process_selected_ingress_frame(&cx, frame)?;
}
}
#[cfg(not(unix))]
{
self.final_result_cache.clear();
Ok(())
}
}
/// Routes one frame admitted by the selected connection reader.
///
/// Matching request-owned response and progress traffic goes to the
/// negotiated executor first. Every other server message retains the
/// Client's existing callback, notification, and sequential-wait path.
fn process_selected_ingress_frame(
&mut self,
cx: &Cx,
frame: ReceivedTransportFrame,
) -> McpResult<()> {
if let Err(error) = validate_inbound_typed_message(frame.message()) {
return Err(self.terminate_connection(error));
}
if matches!(frame.message(), JsonRpcMessage::Response(_)) {
let route = self
.route_received_response(frame)
.map_err(|error| self.terminate_connection(error))?;
if matches!(
route,
ResponseRoute::InvalidEnvelope
| ResponseRoute::MissingId
| ResponseRoute::ConnectionClosed
) {
let error = self.responses.terminal_error().unwrap_or_else(|| {
McpError::internal_error("Client response correlation failed")
});
return Err(self.terminate_connection(error));
}
} else {
let JsonRpcMessage::Request(request) = frame.message() else {
unreachable!("a JSON-RPC message is either a request or response")
};
if let Some(executor) = self.multiplexed_executor.clone()
&& executor.owns_selected_stdio_notification(request)
{
executor
.drive_frame(cx, frame)
.map_err(|error| self.terminate_connection(error))?;
return Ok(());
}
if self.cancel_legacy_reverse_callback(request) {
return Ok(());
}
if self.retain_modern_server_notification(&frame)?.is_some() {
return Ok(());
}
if let JsonRpcMessage::Request(request) = frame.message()
&& self.retain_legacy_server_notification(request)?
{
return Ok(());
}
let JsonRpcMessage::Request(request) = frame.into_message() else {
unreachable!("the frame was checked as a JSON-RPC request")
};
if let Some(response) = self.server_request_response(&request) {
if let Err(error) = self.send_server_response_during_receive(response) {
return Err(self.terminate_connection(error));
}
} else if server_notification_kind(&request) == Some(ServerNotificationKind::LogMessage)
&& let Some(params) = request.params.as_ref()
&& let Ok(message) = serde_json::from_value::<LogMessageParams>(params.clone())
{
self.emit_log_message(message);
}
}
Ok(())
}
/// Consumes the immediately preceding cacheable page provenance. A full
/// list never combines a page invalidated during fetch or a different
/// scope or generation with pages already accumulated for that list.
fn final_list_restart_needed(
&mut self,
result_set: &FinalCacheResultSet,
baseline: &mut Option<(FinalCacheGeneration, fastmcp_protocol::CacheScope)>,
) -> bool {
let Some(page) = self.last_final_cache_page.take() else {
return false;
};
let generation_drift = page.generation != self.final_result_cache.begin_fetch(result_set);
let invalidated_during_fetch = matches!(page.miss, Some(FinalCacheMiss::Invalidated));
let scope_drift = baseline.is_some_and(|(_, scope)| scope != page.scope);
if generation_drift || invalidated_during_fetch || scope_drift {
self.final_result_cache.invalidate_result_set(result_set);
return true;
}
if baseline.is_none() {
*baseline = Some((page.generation, page.scope));
}
false
}
/// Sends a notification (no response expected).
fn send_notification<P: serde::Serialize>(&mut self, method: &str, params: P) -> McpResult<()> {
let params_value = serde_json::to_value(params)
.map_err(|e| McpError::internal_error(format!("Failed to serialize params: {e}")))?;
let params_value = self.prepare_request_parameters(params_value)?;
// Create a notification (request without id)
let request = JsonRpcRequest {
jsonrpc: std::borrow::Cow::Borrowed(fastmcp_protocol::JSONRPC_VERSION),
method: method.to_string(),
params: Some(params_value),
id: None,
};
if let Err(error) = self.send_to_server(&JsonRpcMessage::Request(request)) {
return Err(self.record_send_failure(None, error));
}
Ok(())
}
fn send_initialized_notification(&mut self) -> McpResult<()> {
let notification = JsonRpcRequest::initialized_notification();
if let Err(error) = self.send_to_server(&JsonRpcMessage::Request(notification)) {
return Err(self.record_send_failure(None, error));
}
Ok(())
}
/// Sends the exact MCP 2024-11-05 `notifications/roots/list_changed`
/// notification after the client explicitly advertised `roots.listChanged`.
///
/// The notification has no parameters. Modern sessions intentionally reject
/// it: the legacy reverse-notification vocabulary is not a final transport
/// escape hatch.
pub fn roots_list_changed(&mut self) -> McpResult<()> {
self.ensure_initialized()?;
if self.session.selected_era() != Some(ProtocolEra::Legacy2024) {
return Err(McpError::method_not_found(NOTIFICATIONS_ROOTS_LIST_CHANGED));
}
if !self
.session
.client_capabilities()
.roots
.as_ref()
.is_some_and(|roots| roots.list_changed)
{
return Err(McpError::invalid_request(
"MCP 2024-11-05 roots/list_changed requires advertised roots.listChanged",
));
}
let notification = JsonRpcRequest::notification(NOTIFICATIONS_ROOTS_LIST_CHANGED, None);
self.send_to_server(&JsonRpcMessage::Request(notification))
.map_err(|error| self.record_send_failure(None, error))
}
/// Sends a cancellation notification for a locally owned live request.
///
/// Both supported wire forms contain `requestId` and an optional `reason`.
/// Modern notification metadata remains optional and is never synthesized.
/// The live waiter receives local cancellation first and its one late
/// response is discarded through a tombstone.
///
/// On Unix child pipes the control write is one bounded, nonblocking atomic
/// write. The standard library exposes no equivalent safe primitive for
/// child stdin on non-Unix targets, so cancellation there fails the
/// connection explicitly instead of risking an unbounded write.
///
/// # Errors
///
/// Returns an error if the notification cannot be sent.
pub fn cancel_request(
&mut self,
request_id: impl Into<RequestId>,
reason: Option<String>,
) -> McpResult<()> {
let request_id = request_id.into();
self.ensure_initialized()?;
if !self.responses.owns_live_request(&request_id)? {
return Err(McpError::invalid_request(
"Client cancellation requires a locally owned live request ID",
));
}
let control = self.cancellation_control_message(request_id.clone(), reason)?;
let claimed = match self.responses.claim_cancellation_control(&request_id) {
Ok(claimed) => claimed,
Err(error) => return Err(self.terminate_connection(error)),
};
if !claimed {
return Err(McpError::invalid_request(
"Client cancellation was already committed for this request ID",
));
}
match self
.responses
.tombstone(&request_id, McpError::request_cancelled())
{
Ok(true) => {}
Ok(false) => {
return Err(McpError::invalid_request(
"Client cancellation requires a locally owned live request ID",
));
}
Err(error) => return Err(self.terminate_connection(error)),
}
if let Err(control_error) = self.send_bounded_control_message(control) {
let terminal = self.terminate_connection(control_error);
return Err(terminal);
}
Ok(())
}
/// Records a transport send failure at the narrowest valid scope.
///
/// Codec failures happen before a complete frame is committed and affect
/// only the request being encoded. Every other send failure makes this
/// shared stdio connection unusable (or observes its shared `Cx` as
/// cancelled), so all registered waiters receive the same terminal error.
fn record_send_failure(
&mut self,
request_id: Option<&RequestId>,
error: TransportError,
) -> McpError {
let is_connection_terminal = !matches!(&error, TransportError::Codec(_));
let error = transport_error_to_mcp(error);
if is_connection_terminal {
return self.terminate_connection(error);
} else if let Some(request_id) = request_id {
self.responses.fail(request_id, error.clone());
}
error
}
fn send_bounded_control_message(&mut self, message: JsonRpcMessage) -> McpResult<()> {
#[cfg(unix)]
{
self.response_sender
.lock()
.map_err(|_| McpError::internal_error("Client stdio response writer failed"))?
.try_send_control_message(&message)
.map_err(transport_error_to_mcp)
}
#[cfg(not(unix))]
{
let _ = message;
Err(McpError::internal_error(
"Nonblocking stdio control is unavailable on this platform",
))
}
}
fn send_server_response_during_receive(&mut self, message: JsonRpcMessage) -> McpResult<()> {
// A peer-controlled server request must not turn the surrounding
// response deadline into an unbounded child-stdin write on platforms
// where child pipes expose the required nonblocking primitive.
send_child_server_response_during_receive(&self.response_sender, &self.cx, &message)
}
fn send_timeout_cancellation_control(&mut self, request_id: &RequestId) -> McpResult<()> {
let control = self.cancellation_control_message(request_id.clone(), None)?;
self.send_bounded_control_message(control)
}
fn finish_committed_request_locally(
&mut self,
request_id: &RequestId,
outcome: McpError,
) -> McpError {
let cancellation_claim = self.responses.claim_cancellation_control(request_id);
match self.responses.tombstone(request_id, outcome.clone()) {
Ok(true) => match cancellation_claim {
Ok(true) => {
if let Err(control_error) = self.send_timeout_cancellation_control(request_id)
&& self.responses.terminal_error().is_none()
{
let _ = self.terminate_connection(control_error);
}
}
Ok(false) => {}
Err(error) => {
let _ = self.terminate_connection(error);
}
},
Ok(false) => {}
Err(capacity_or_terminal_error) => {
let _ = self.terminate_connection(capacity_or_terminal_error);
}
}
outcome
}
fn timeout_committed_request(
&mut self,
request_id: &RequestId,
source: RequestTimeoutSource,
) -> McpError {
self.finish_committed_request_locally(request_id, request_timeout_error(source))
}
fn finish_partial_frame_timeout(
&mut self,
request_id: &RequestId,
source: RequestTimeoutSource,
) -> McpError {
let timeout = request_timeout_error(source);
// The explicit deadline still consumes this ID's sole cancellation
// marker. The transport has already failed closed on the partial frame,
// so no control write can be attempted without replacing the selected
// request-local timeout or violating frame alignment.
let cancellation_claim = self.responses.claim_cancellation_control(request_id);
// The peer supplied an incomplete NDJSON frame, so no aligned late
// response can retire a tombstone. Preserve the request-local timeout
// as first outcome, then fail the now-unusable connection with that
// same typed source.
let _ = self.responses.fail(request_id, timeout.clone());
match cancellation_claim {
Ok(_) => {
let _ = self.terminate_connection(timeout.clone());
}
Err(error) => {
let _ = self.terminate_connection(error);
}
}
timeout
}
fn finish_open_context_interruption(
&mut self,
request_id: &RequestId,
context_error: McpError,
) -> McpError {
let outcome = self.finish_committed_request_locally(request_id, context_error);
// The stored context belongs to this direct connection and remains
// exhausted after the current request. Send the cancellation control
// first, then make that connection-wide terminal state explicit.
if self.responses.terminal_error().is_none() {
let _ = self.terminate_connection(outcome.clone());
}
outcome
}
fn route_received_response(
&mut self,
frame: ReceivedTransportFrame,
) -> McpResult<ResponseRoute> {
let JsonRpcMessage::Response(response) = frame.message() else {
return Err(McpError::internal_error(
"Client response routing received a request frame",
));
};
if let Some(executor) = &self.multiplexed_executor
&& response
.id
.as_ref()
.is_some_and(|response_id| executor.owns_response_id(response_id))
{
// The selected Client is the one reader. Giving the full admitted
// frame to the matching request executor preserves result source
// and prevents a generic owner from entering the older sequential
// response registry.
let cx = self.cx.clone();
executor.drive_frame(&cx, frame)?;
return Ok(ResponseRoute::Delivered);
}
let response = response.clone();
let raw_result = raw_result_from_admitted_response(&response, frame, "stdio")?;
Ok(self.responses.route_with_raw_result(response, raw_result))
}
fn finish_timeout_after_complete_frame(
&mut self,
request_id: &RequestId,
frame: ReceivedTransportFrame,
source: RequestTimeoutSource,
) -> McpError {
let timeout = request_timeout_error(source);
if let Err(protocol_error) = validate_inbound_typed_message(frame.message()) {
let _ = self.responses.fail(request_id, timeout.clone());
let _ = self.terminate_connection(protocol_error);
return timeout;
}
let timeout = self.timeout_committed_request(request_id, source);
if self.responses.terminal_error().is_some() {
return timeout;
}
if matches!(frame.message(), JsonRpcMessage::Response(_)) {
let route = match self.route_received_response(frame) {
Ok(route) => route,
Err(error) => {
let _ = self.terminate_connection(error);
return timeout;
}
};
if matches!(
route,
ResponseRoute::InvalidEnvelope
| ResponseRoute::MissingId
| ResponseRoute::ConnectionClosed
) {
let terminal_error = self.responses.terminal_error().unwrap_or_else(|| {
McpError::internal_error("Client response correlation failed")
});
let _ = self.terminate_connection(terminal_error);
}
} else {
let JsonRpcMessage::Request(request) = frame.message() else {
unreachable!("a JSON-RPC message is either a request or response")
};
if let Some(executor) = self.multiplexed_executor.clone()
&& executor.owns_selected_stdio_notification(request)
{
let cx = self.cx.clone();
if let Err(error) = executor.drive_frame(&cx, frame) {
let _ = self.terminate_connection(error);
}
return timeout;
}
if self.cancel_legacy_reverse_callback(request) {
return timeout;
}
match self.retain_modern_server_notification(&frame) {
Ok(Some(_)) => return timeout,
Ok(None) => {}
Err(error) => {
let _ = self.terminate_connection(error);
return timeout;
}
}
if let JsonRpcMessage::Request(request) = frame.message() {
match self.retain_legacy_server_notification(request) {
Ok(true) => return timeout,
Ok(false) => {}
Err(error) => {
let _ = self.terminate_connection(error);
return timeout;
}
}
}
let JsonRpcMessage::Request(request) = frame.into_message() else {
unreachable!("the frame was checked as a JSON-RPC request")
};
if let Some(response) = self.server_request_response(&request) {
if let Err(error) = self.send_bounded_control_message(response) {
let _ = self.terminate_connection(error);
}
} else if server_notification_kind(&request) == Some(ServerNotificationKind::LogMessage)
&& let Some(params) = request.params.as_ref()
&& let Ok(message) = serde_json::from_value::<LogMessageParams>(params.clone())
{
self.emit_log_message(message);
}
}
timeout
}
/// Receives a response from the transport, validating the response ID.
fn recv_response(
&mut self,
waiter: ResponseWaiter,
deadlines: RequestDeadlines,
) -> McpResult<ReceivedJsonRpcResponse> {
let cx = self.cx.clone();
self.recv_response_with_cx(&cx, waiter, deadlines)
}
fn recv_response_with_cx(
&mut self,
cx: &Cx,
waiter: ResponseWaiter,
deadlines: RequestDeadlines,
) -> McpResult<ReceivedJsonRpcResponse> {
let no_request_cancellation = McpRequestCancellation::new();
self.recv_response_with_request_cancellation(
cx,
waiter,
deadlines,
&no_request_cancellation,
RequestCancellationTerminalElection::CancelFirst,
)
}
fn recv_response_with_request_cancellation(
&mut self,
cx: &Cx,
mut waiter: ResponseWaiter,
deadlines: RequestDeadlines,
cancellation: &McpRequestCancellation,
terminal_election: RequestCancellationTerminalElection,
) -> McpResult<ReceivedJsonRpcResponse> {
let expected_id = waiter.id.clone();
loop {
if let Some(executor) = &self.multiplexed_executor {
executor
.service(cx)
.map_err(|error| self.terminate_connection(error))?;
}
if let Some(response) = waiter.try_response()? {
debug_assert!(
response
.id
.as_ref()
.is_some_and(|response_id| response_id.correlates_with(&expected_id))
);
if cancellation.is_cancel_requested() && !terminal_election.response_wins(&response)
{
// The peer has already committed its terminal response, so
// there is no longer a live request on which to send a
// cancellation control. Ordinary terminals remain
// cancellation-first at this handoff; only a validated
// durable task handle may win this election.
return Err(McpError::request_cancelled());
}
return Ok(response);
}
if cancellation.is_cancel_requested() {
self.cancel_request(expected_id.clone(), None)?;
return Err(McpError::request_cancelled());
}
if let Some(kind) = deadlines.expired_at(Instant::now()) {
return Err(self.timeout_committed_request(&expected_id, kind));
}
let receive_deadline = self.reverse_callback_poll_deadline(deadlines.next());
let (frame, received_at) = match self.recv_next_child_frame(cx, Some(receive_deadline))
{
Ok(received) => received,
Err(TransportError::ReceiveDeadlineExceeded) => {
if receive_deadline < deadlines.next() && !self.transport_is_closed() {
self.drain_completed_reverse_callbacks()
.map_err(|error| self.terminate_connection(error))?;
continue;
}
let kind = deadlines
.expired_at(Instant::now())
.unwrap_or_else(|| deadlines.next_kind());
if self.transport_is_closed() {
return Err(self.finish_partial_frame_timeout(&expected_id, kind));
}
return Err(self.timeout_committed_request(&expected_id, kind));
}
Err(TransportError::Timeout) if !self.transport_is_closed() => {
return Err(self.finish_open_context_interruption(
&expected_id,
McpError::internal_error("Request timed out"),
));
}
Err(TransportError::Cancelled) if !self.transport_is_closed() => {
return Err(self.finish_open_context_interruption(
&expected_id,
McpError::request_cancelled(),
));
}
Err(error) => {
let error = transport_error_to_mcp(error);
return Err(self.terminate_connection(error));
}
};
if let Some(kind) = deadlines.expired_at(received_at) {
return Err(self.finish_timeout_after_complete_frame(&expected_id, frame, kind));
}
if let Err(error) = validate_inbound_typed_message(frame.message()) {
return Err(self.terminate_connection(error));
}
if matches!(frame.message(), JsonRpcMessage::Response(_)) {
// The registry preserves responses for other registered
// waiters and never lets an unknown/missing ID consume this
// request's response slot.
let route = self
.route_received_response(frame)
.map_err(|error| self.terminate_connection(error))?;
if matches!(
route,
ResponseRoute::InvalidEnvelope
| ResponseRoute::MissingId
| ResponseRoute::ConnectionClosed
) {
let error = self.responses.terminal_error().unwrap_or_else(|| {
McpError::internal_error("Client response correlation failed")
});
return Err(self.terminate_connection(error));
}
} else {
let JsonRpcMessage::Request(request) = frame.message() else {
unreachable!("a JSON-RPC message is either a request or response")
};
if let Some(executor) = self.multiplexed_executor.clone()
&& executor.owns_selected_stdio_notification(request)
{
executor
.drive_frame(cx, frame)
.map_err(|error| self.terminate_connection(error))?;
continue;
}
if self.cancel_legacy_reverse_callback(request) {
continue;
}
match self.retain_modern_server_notification(&frame) {
Ok(Some(_)) => continue,
Ok(None) => {}
Err(error) => return Err(self.terminate_connection(error)),
}
if let JsonRpcMessage::Request(request) = frame.message() {
match self.retain_legacy_server_notification(request) {
Ok(true) => continue,
Ok(false) => {}
Err(error) => return Err(self.terminate_connection(error)),
}
}
let JsonRpcMessage::Request(request) = frame.into_message() else {
unreachable!("the frame was checked as a JSON-RPC request")
};
if let Some(response) = self.server_request_response(&request) {
if let Err(error) = self.send_server_response_during_receive(response) {
return Err(self.terminate_connection(error));
}
continue;
}
if server_notification_kind(&request) == Some(ServerNotificationKind::LogMessage)
&& let Some(params) = request.params.as_ref()
&& let Ok(message) = serde_json::from_value::<LogMessageParams>(params.clone())
{
self.emit_log_message(message);
}
}
}
}
/// Collects one final `subscriptions/listen` stream until its complete
/// result. The listener owns only its acknowledgement, subscription
/// change events, and matching cancellation. Its retained events use the
/// same bound as the connection-wide final notification queue. A matching
/// cancellation begins teardown but leaves the waiter live until its
/// correlated complete result is admitted. Ordinary final log/progress
/// notifications keep their existing connection-wide handling.
fn recv_subscription_listener(
&mut self,
mut waiter: ResponseWaiter,
core_request: &CoreRequest,
requested: &SubscriptionFilter,
deadlines: RequestDeadlines,
) -> McpResult<SubscriptionListenCollector> {
let cx = self.cx.clone();
let expected_id = waiter.id.clone();
let mut accepted_filter = None;
let mut notifications = Vec::new();
#[cfg(feature = "tasks")]
let mut task_notifications = Vec::new();
let mut server_teardown_requested = false;
loop {
if let Some(response) = waiter.try_response()? {
debug_assert!(
response
.id
.as_ref()
.is_some_and(|response_id| response_id.correlates_with(&expected_id))
);
if let Some(error) = response.error.clone() {
return Err(json_rpc_error_to_mcp(error));
}
let raw_result = response.raw_result.as_deref().ok_or_else(|| {
self.terminate_connection(McpError::invalid_request(
"Final subscriptions/listen response lost its admitted result source",
))
})?;
let result = core_request
.decode_response_result(&response, raw_result)
.map_err(|error| {
self.terminate_connection(McpError::invalid_request(format!(
"Invalid final subscriptions/listen termination: {error}"
)))
})?;
let CoreResult::Final(FinalCoreResult::SubscriptionsListen {
result: terminal,
subscription_id,
..
}) = result
else {
return Err(
self.terminate_connection(subscription_listener_protocol_error(
"Subscription listener received a non-listen terminal result",
)),
);
};
if !subscription_id.correlates_with(&expected_id) {
return Err(
self.terminate_connection(subscription_listener_protocol_error(
"Subscription listener terminal ID does not match its request",
)),
);
}
let Some(accepted_filter) = accepted_filter else {
return Err(
self.terminate_connection(subscription_listener_protocol_error(
"Subscription listener terminated before acknowledgement",
)),
);
};
return Ok(SubscriptionListenCollector {
subscription_id,
accepted_filter,
notifications,
#[cfg(feature = "tasks")]
task_notifications,
terminal,
});
}
if let Some(kind) = deadlines.expired_at(Instant::now()) {
return Err(self.timeout_committed_request(&expected_id, kind));
}
let receive_deadline = self.reverse_callback_poll_deadline(deadlines.next());
let (frame, received_at) = match self.recv_next_child_frame(&cx, Some(receive_deadline))
{
Ok(received) => received,
Err(TransportError::ReceiveDeadlineExceeded) => {
if receive_deadline < deadlines.next() && !self.transport_is_closed() {
self.drain_completed_reverse_callbacks()
.map_err(|error| self.terminate_connection(error))?;
continue;
}
let kind = deadlines
.expired_at(Instant::now())
.unwrap_or_else(|| deadlines.next_kind());
if self.transport_is_closed() {
return Err(self.finish_partial_frame_timeout(&expected_id, kind));
}
return Err(self.timeout_committed_request(&expected_id, kind));
}
Err(TransportError::Timeout) if !self.transport_is_closed() => {
return Err(self.finish_open_context_interruption(
&expected_id,
McpError::internal_error("Request timed out"),
));
}
Err(TransportError::Cancelled) if !self.transport_is_closed() => {
return Err(self.finish_open_context_interruption(
&expected_id,
McpError::request_cancelled(),
));
}
Err(TransportError::Closed) => {
let message = if server_teardown_requested {
"Subscription listener reached EOF after cancellation before terminal complete result"
} else {
"Subscription listener reached EOF before terminal complete result"
};
return Err(
self.terminate_connection(subscription_listener_protocol_error(message))
);
}
Err(error) => {
return Err(self.terminate_connection(transport_error_to_mcp(error)));
}
};
if let Some(kind) = deadlines.expired_at(received_at) {
return Err(self.finish_timeout_after_complete_frame(&expected_id, frame, kind));
}
if let Err(error) = validate_inbound_typed_message(frame.message()) {
return Err(self.terminate_connection(error));
}
if matches!(frame.message(), JsonRpcMessage::Response(_)) {
let route = self
.route_received_response(frame)
.map_err(|error| self.terminate_connection(error))?;
if matches!(
route,
ResponseRoute::InvalidEnvelope
| ResponseRoute::MissingId
| ResponseRoute::ConnectionClosed
) {
let error = self.responses.terminal_error().unwrap_or_else(|| {
McpError::internal_error("Client response correlation failed")
});
return Err(self.terminate_connection(error));
}
} else {
let JsonRpcMessage::Request(request) = frame.message() else {
unreachable!("a JSON-RPC message is either a request or response")
};
if let Some(executor) = self.multiplexed_executor.clone()
&& executor.owns_selected_stdio_notification(request)
{
executor
.drive_frame(&cx, frame)
.map_err(|error| self.terminate_connection(error))?;
continue;
}
if self.cancel_legacy_reverse_callback(request) {
continue;
}
#[cfg(feature = "tasks")]
if request.id.is_none() && request.method == TASK_STATUS_NOTIFICATION {
let Some(accepted_filter) = accepted_filter.as_ref() else {
return Err(self.terminate_connection(
subscription_listener_protocol_error(
"Subscription listener received a Tasks event before acknowledgement",
),
));
};
let accepted_task_ids = match task_subscription_ids(accepted_filter) {
Ok(Some(task_ids)) => task_ids,
_ => {
return Err(self.terminate_connection(
subscription_listener_protocol_error(
"Subscription listener received a Tasks event without an acknowledged Tasks filter",
),
));
}
};
let notification: FinalTaskStatusNotification = match serde_json::from_value(
serde_json::to_value(&request).map_err(|error| {
McpError::internal_error(format!(
"Failed to inspect Tasks subscription event: {error}"
))
})?,
) {
Ok(notification) => notification,
Err(_) => {
return Err(self.terminate_connection(
subscription_listener_protocol_error(
"Subscription listener received an invalid Tasks event",
),
));
}
};
let subscription_id = notification
.params
.meta
.as_ref()
.and_then(|metadata| metadata.get(FINAL_SUBSCRIPTION_ID_META_KEY))
.and_then(|value| serde_json::from_value::<RequestId>(value.clone()).ok());
if !subscription_id.as_ref().is_some_and(|subscription_id| {
subscription_id.correlates_with(&expected_id)
}) {
return Err(self.terminate_connection(
subscription_listener_protocol_error(
"Tasks event subscription ID does not match the listen request",
),
));
}
if !accepted_task_ids
.iter()
.any(|task_id| task_id == ¬ification.params.task.base().task_id)
{
return Err(self.terminate_connection(
subscription_listener_protocol_error(
"Tasks event taskId is outside the acknowledged filter",
),
));
}
if task_notifications.len() >= MAX_QUEUED_FINAL_SERVER_NOTIFICATIONS {
return Err(self.terminate_connection(McpError::invalid_request(
FINAL_SERVER_NOTIFICATION_QUEUE_OVERFLOW_ERROR,
)));
}
task_notifications.push(notification);
continue;
}
if request.method == "notifications/cancelled" {
let cancellation = match CancellationWireMessage::decode(
ProtocolEra::Modern2026,
CancellationSender::Server,
request,
) {
Ok(CancellationWireMessage::Modern2026 { params, .. }) => params,
Ok(CancellationWireMessage::Legacy2024 { .. }) | Err(_) => continue,
};
if !cancellation.request_id.correlates_with(&expected_id) {
continue;
}
if let Some(metadata_subscription_id) = cancellation
.meta
.as_ref()
.and_then(|metadata| metadata.get(FINAL_SUBSCRIPTION_ID_META_KEY))
.and_then(|value| serde_json::from_value::<RequestId>(value.clone()).ok())
&& !metadata_subscription_id.correlates_with(&expected_id)
{
continue;
}
server_teardown_requested = true;
continue;
}
if is_final_server_notification_method(request) {
let raw_params = match raw_notification_params_from_frame(frame.source()) {
Ok(raw_params) => raw_params,
Err(_) => {
return Err(self.terminate_connection(
subscription_listener_protocol_error(
"Subscription listener lost raw final notification params",
),
));
}
};
let notification =
match decode_final_server_notification(request, raw_params.as_deref()) {
Ok(notification) => notification,
Err(_) => {
return Err(self.terminate_connection(
subscription_listener_protocol_error(
"Subscription listener received an invalid final notification",
),
));
}
};
match notification {
ServerNotification::SubscriptionsAcknowledged(acknowledgement) => {
if accepted_filter.is_some() {
return Err(self.terminate_connection(
subscription_listener_protocol_error(
"Subscription listener received a duplicate acknowledgement",
),
));
}
if let Err(error) = validate_subscription_acknowledgement(
&expected_id,
requested,
&acknowledgement,
) {
return Err(self.terminate_connection(error));
}
accepted_filter = Some(acknowledgement.notifications);
}
ServerNotification::Cancelled(_) => {
return Err(self.terminate_connection(
subscription_listener_protocol_error(
"Subscription cancellation bypassed the final cancellation codec",
),
));
}
notification @ (ServerNotification::ResourcesListChanged(_)
| ServerNotification::ToolsListChanged(_)
| ServerNotification::PromptsListChanged(_)
| ServerNotification::ResourceUpdated(_)) => {
let Some(accepted_filter) = accepted_filter.as_ref() else {
return Err(self.terminate_connection(
subscription_listener_protocol_error(
"Subscription listener received an event before acknowledgement",
),
));
};
if let Err(error) = validate_subscription_notification_filter(
¬ification,
accepted_filter,
) {
return Err(self.terminate_connection(error));
}
if notifications.len() >= MAX_QUEUED_FINAL_SERVER_NOTIFICATIONS {
return Err(self.terminate_connection(McpError::invalid_request(
FINAL_SERVER_NOTIFICATION_QUEUE_OVERFLOW_ERROR,
)));
}
notifications.push(notification);
}
ServerNotification::Progress(_) | ServerNotification::Message(_) => {
if let Err(error) = self.retain_modern_server_notification(&frame) {
return Err(self.terminate_connection(error));
}
}
}
continue;
}
if let Some(response) = self.server_request_response(request) {
if let Err(error) = self.send_server_response_during_receive(response) {
return Err(self.terminate_connection(error));
}
continue;
}
if server_notification_kind(request) == Some(ServerNotificationKind::LogMessage)
&& let Some(params) = request.params.as_ref()
&& let Ok(message) = serde_json::from_value::<LogMessageParams>(params.clone())
{
self.emit_log_message(message);
}
}
}
}
/// Performs the initialization handshake.
fn initialize(
&mut self,
client_info: ClientInfo,
capabilities: ClientCapabilities,
) -> McpResult<ClientInitialization> {
match self.session.protocol_plan().policy() {
ProtocolPolicy::ModernOnly => self.initialize_modern(client_info, capabilities),
// The public Auto entry point performs its isolated modern probe
// before constructing this legacy client. Retaining this exact
// path here keeps deferred initialization from converting a
// configured legacy process into a second selection attempt.
ProtocolPolicy::Auto | ProtocolPolicy::LegacyOnly => self
.initialize_legacy(client_info, capabilities)
.map(ClientInitialization::Legacy),
}
}
fn replace_session_after_initialization(
&mut self,
initialization: ClientInitialization,
) -> McpResult<()> {
let client_info = self.session.client_info().clone();
let client_implementation = self.session.modern_client_implementation();
let client_capabilities = self.session.client_capabilities().clone();
let mcp_apps_settings = self.session.mcp_apps_settings().cloned();
let client_extension_runtime = self.session.client_extension_runtime().cloned();
let protocol_plan = self.session.protocol_plan().clone();
let mut session = match initialization {
ClientInitialization::Legacy(result) => ClientSession::try_new(
client_info,
client_capabilities,
result.server_info,
result.capabilities,
result.protocol_version,
)
.map_err(|_| McpError::internal_error(UNSUPPORTED_PROTOCOL_VERSION_ERROR))?
.with_legacy_instructions(result.instructions),
ClientInitialization::Modern {
server_info,
discovery,
} => ClientSession::try_new(
client_info,
client_capabilities,
server_info,
ServerCapabilities::default(),
MODERN_PROTOCOL_VERSION.to_owned(),
)
.map_err(|_| McpError::internal_error(UNSUPPORTED_PROTOCOL_VERSION_ERROR))?
.with_server_discovery(discovery),
};
session = session.with_client_implementation(client_implementation);
session = session.with_mcp_apps_settings(mcp_apps_settings);
session = session.with_client_extension_runtime(client_extension_runtime);
session.negotiate_client_extensions_after_discovery()?;
let apps_activation_receipt = if session.generic_mcp_apps_configured() {
session.generic_mcp_apps_activation_receipt()
} else {
session.server_discovery().and_then(|discovery| {
mcp_apps_activation_receipt(session.mcp_apps_settings(), discovery)
})
};
session.set_mcp_apps_activation_receipt(apps_activation_receipt);
self.session = session.try_with_protocol_plan(protocol_plan).map_err(|_| {
McpError::internal_error("Configured protocol policy rejects the negotiated era")
})?;
Ok(())
}
fn initialize_legacy(
&mut self,
client_info: ClientInfo,
capabilities: ClientCapabilities,
) -> McpResult<InitializeResult> {
let params = InitializeParams {
protocol_version: PROTOCOL_VERSION.to_string(),
capabilities,
client_info,
};
let result = self.send_request("initialize", params)?;
validate_initialize_result(&result)?;
Ok(result)
}
fn initialize_modern(
&mut self,
_client_info: ClientInfo,
_capabilities: ClientCapabilities,
) -> McpResult<ClientInitialization> {
let params = serde_json::to_value(ServerDiscoverRequest::default())
.map_err(|error| {
McpError::internal_error(format!(
"Failed to serialize modern server/discover parameters: {error}"
))
})
.and_then(|params| self.with_modern_request_metadata(params))?;
let received = self.send_prepared_request(SERVER_DISCOVER_METHOD, params)?;
self.decode_modern_discovery_initialization(received)
}
/// Runs Auto's disposable first `server/discover` exchange. Only the
/// structured response path below can surface a fallback signal; every
/// decoder, framing, correlation, transport, and caller-cancellation
/// failure remains an ordinary terminal error.
fn initialize_modern_for_auto_probe(
&mut self,
_client_info: ClientInfo,
_capabilities: ClientCapabilities,
) -> McpResult<Result<ClientInitialization, AutoStdioFallbackSignal>> {
let params = serde_json::to_value(ServerDiscoverRequest::default())
.map_err(|error| {
McpError::internal_error(format!(
"Failed to serialize modern server/discover parameters: {error}"
))
})
.and_then(|params| self.with_modern_request_metadata(params))?;
match self.send_modern_discovery_probe(params)? {
Ok(received) => self
.decode_modern_discovery_initialization(received)
.map(Ok),
Err(signal) => Ok(Err(signal)),
}
}
fn decode_modern_discovery_initialization(
&mut self,
received: ReceivedPreparedResult,
) -> McpResult<ClientInitialization> {
let result_source = received.raw_result.as_deref().ok_or_else(|| {
self.terminate_connection(McpError::invalid_request(
"Modern server/discover response lost its admitted result source",
))
})?;
let result: ServerDiscoverResult = serde_json::from_str(result_source).map_err(|_| {
self.terminate_connection(McpError::internal_error(INVALID_RESPONSE_PAYLOAD_ERROR))
})?;
if !result
.supported_versions()
.iter()
.any(|version| version == MODERN_PROTOCOL_VERSION)
{
return Err(McpError::internal_error(UNSUPPORTED_PROTOCOL_VERSION_ERROR));
}
let server_info = result.server_info().cloned().ok_or_else(|| {
McpError::internal_error("Modern server/discover response has no _meta server info")
})?;
Ok(ClientInitialization::Modern {
server_info,
discovery: result,
})
}
/// Lists one page of tools and returns its negotiated core result.
///
/// The caller supplies the opaque peer cursor, if any. A modern session
/// returns [`CoreResult::Final`] with [`FinalCoreResult::ToolsList`]; an
/// exact legacy session returns [`CoreResult::Legacy`].
///
/// # Errors
///
/// Returns an error if the request fails or its selected-era result
/// contract is contradicted. A contradictory core result terminates the
/// connection.
pub fn list_tools_typed(&mut self, cursor: Option<&str>) -> McpResult<CoreResult> {
self.list_tools_typed_with_params(ListToolsParams {
cursor: cursor.map(ToOwned::to_owned),
..ListToolsParams::default()
})
}
/// Lists one page of tools with explicit include/exclude tag filters.
///
/// The tag filters are part of the modern list-cache key so a filtered
/// page cannot be served from an unfiltered one.
pub fn list_tools_typed_with_params(
&mut self,
params: ListToolsParams,
) -> McpResult<CoreResult> {
self.ensure_initialized()?;
let cursor = params.cursor.clone();
let semantic_parameters = list_tools_semantic_parameters(¶ms);
self.cached_final_core_request(
"tools/list",
semantic_parameters,
cursor.as_deref(),
FinalCacheResultSet::Tools,
move |client| client.send_typed_core_request("tools/list", params),
)
}
/// Lists one page of tools under a request-local cancellation domain.
///
/// A cancellation observed before send makes no transport contact. One
/// observed after commit sends the selected-era cancellation control.
pub fn list_tools_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
cursor: Option<&str>,
) -> McpResult<CoreResult> {
self.list_tools_with_params_and_cancellation(
cx,
cancellation,
ListToolsParams {
cursor: cursor.map(ToOwned::to_owned),
..ListToolsParams::default()
},
)
}
/// Lists one tag-filtered tools page under a request-local cancellation domain.
pub fn list_tools_with_params_and_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
params: ListToolsParams,
) -> McpResult<CoreResult> {
let parameters = serde_json::to_value(params).map_err(|error| {
McpError::internal_error(format!(
"Client tools/list parameters could not serialize: {error}"
))
})?;
self.request_core_with_cancellation(cx, cancellation, "tools/list", parameters, |_| {})
}
/// Lists one page of resources under a request-local cancellation domain.
///
/// A cancellation observed before send makes no transport contact. One
/// observed after commit sends the selected-era cancellation control.
pub fn list_resources_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
cursor: Option<&str>,
) -> McpResult<CoreResult> {
self.list_resources_with_params_and_cancellation(
cx,
cancellation,
ListResourcesParams {
cursor: cursor.map(ToOwned::to_owned),
..ListResourcesParams::default()
},
)
}
/// Lists one tag-filtered resources page under a request-local cancellation domain.
pub fn list_resources_with_params_and_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
params: ListResourcesParams,
) -> McpResult<CoreResult> {
let parameters = serde_json::to_value(params).map_err(|error| {
McpError::internal_error(format!(
"Client resources/list parameters could not serialize: {error}"
))
})?;
self.request_core_with_cancellation(cx, cancellation, "resources/list", parameters, |_| {})
}
/// Lists one page of resource templates under a request-local cancellation
/// domain.
pub fn list_resource_templates_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
cursor: Option<&str>,
) -> McpResult<CoreResult> {
self.list_resource_templates_with_params_and_cancellation(
cx,
cancellation,
ListResourceTemplatesParams {
cursor: cursor.map(ToOwned::to_owned),
..ListResourceTemplatesParams::default()
},
)
}
/// Lists one tag-filtered templates page under a request-local cancellation domain.
pub fn list_resource_templates_with_params_and_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
params: ListResourceTemplatesParams,
) -> McpResult<CoreResult> {
let parameters = serde_json::to_value(params).map_err(|error| {
McpError::internal_error(format!(
"Client resources/templates/list parameters could not serialize: {error}"
))
})?;
self.request_core_with_cancellation(
cx,
cancellation,
"resources/templates/list",
parameters,
|_| {},
)
}
/// Lists one page of prompts under a request-local cancellation domain.
pub fn list_prompts_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
cursor: Option<&str>,
) -> McpResult<CoreResult> {
self.list_prompts_with_params_and_cancellation(
cx,
cancellation,
ListPromptsParams {
cursor: cursor.map(ToOwned::to_owned),
..ListPromptsParams::default()
},
)
}
/// Lists one tag-filtered prompts page under a request-local cancellation domain.
pub fn list_prompts_with_params_and_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
params: ListPromptsParams,
) -> McpResult<CoreResult> {
let parameters = serde_json::to_value(params).map_err(|error| {
McpError::internal_error(format!(
"Client prompts/list parameters could not serialize: {error}"
))
})?;
self.request_core_with_cancellation(cx, cancellation, "prompts/list", parameters, |_| {})
}
/// Lists available tools.
///
/// This convenience API follows peer cursors and returns the flattened
/// legacy-compatible tool vector. Use [`Self::list_tools_typed`] to retain
/// the negotiated, single-page core result.
///
/// # Errors
///
/// Returns an error if the request fails.
pub fn list_tools(&mut self) -> McpResult<Vec<Tool>> {
self.list_tools_with_params(ListToolsParams::default())
}
/// Follows peer cursors for one tag-filtered tools/list query.
pub fn list_tools_with_params(&mut self, params: ListToolsParams) -> McpResult<Vec<Tool>> {
self.ensure_initialized()?;
let include_tags = params.include_tags.clone();
let exclude_tags = params.exclude_tags.clone();
let mut restarts = 0;
'rebuild: loop {
let mut all = Vec::new();
let mut cursor: Option<String> = params.cursor.clone();
let mut budget = PaginationBudget::new();
let mut baseline = None;
loop {
budget.begin_page()?;
let page_params = ListToolsParams {
cursor: cursor.clone(),
include_tags: include_tags.clone(),
exclude_tags: exclude_tags.clone(),
};
let (tools, next_cursor) =
convenience_tools_page(self.list_tools_typed_with_params(page_params)?)?;
if self.final_list_restart_needed(&FinalCacheResultSet::Tools, &mut baseline) {
restarts += 1;
if restarts > 1 {
return Err(McpError::invalid_request(
FINAL_CACHE_LIST_RESTART_LIMIT_ERROR,
));
}
continue 'rebuild;
}
budget.account_page(&tools)?;
all.extend(tools);
cursor = budget.admit_next_cursor(next_cursor)?;
if cursor.is_none() {
return Ok(all);
}
}
}
}
/// Acquires at most one bounded page of tools.
///
/// Unlike [`Self::list_tools`], this method never follows the peer's next
/// cursor. [`BoundedListPage::local_truncated`] reports entries omitted from
/// the current peer page, while [`BoundedListPage::peer_has_more`] reports a
/// peer-provided following page.
///
/// # Errors
///
/// Returns an error if the caller's limits or cursor are invalid, the
/// request fails, or the peer returns an oversized or non-advancing cursor.
pub fn list_tools_page(
&mut self,
cursor: Option<&str>,
limits: ListPageLimits,
) -> McpResult<BoundedListPage<Tool>> {
self.list_tools_page_with_params(
ListToolsParams {
cursor: cursor.map(ToOwned::to_owned),
..ListToolsParams::default()
},
limits,
)
}
/// Acquires at most one bounded, tag-filtered tools page.
pub fn list_tools_page_with_params(
&mut self,
params: ListToolsParams,
limits: ListPageLimits,
) -> McpResult<BoundedListPage<Tool>> {
let request_cursor = params.cursor.clone();
let cursor_parameter = validate_list_page_request(request_cursor.as_deref(), limits)?;
self.ensure_initialized()?;
let params = ListToolsParams {
cursor: cursor_parameter,
..params
};
let (tools, next_cursor) =
convenience_tools_page(self.list_tools_typed_with_params(params)?)?;
bounded_list_page(tools, request_cursor.as_deref(), next_cursor, limits)
}
/// Drives one stdio MRTR operation with one caller context and one
/// operation-wide absolute deadline.
///
/// Each continuation rebuilds its parameters from `original_parameters`,
/// adding only the latest `inputResponses` and `requestState`. This keeps
/// prior continuation state from leaking into a later round while every
/// committed retry still receives a fresh JSON-RPC request ID.
fn drive_mrtr_retry<F>(
&mut self,
method: &str,
original_parameters: serde_json::Value,
mut respond: F,
) -> McpResult<CoreResult>
where
F: FnMut(&InputRequiredResult) -> McpResult<MrtrInputResponses>,
{
self.ensure_initialized()?;
let cx = self.cx.clone();
let deadline = Instant::now()
.checked_add(self.timeout_policy.absolute_timeout())
.ok_or_else(|| {
McpError::internal_error("MRTR operation deadline exceeds the clock range")
})?;
let limits =
MrtrDriverLimits::new(MAX_MRTR_CONTINUATION_ROUNDS, MAX_MRTR_TOTAL_INPUT_RESPONSES)?;
let mut driver = MrtrDriver::new(&cx, deadline, limits)?;
let mut parameters = original_parameters.clone();
loop {
driver.before_request()?;
let result = self.send_typed_core_request_with_cx_until(
&cx,
driver.deadline(),
method,
parameters,
)?;
let Some(input_required) = mrtr_input_required_for_method(method, &result) else {
return Ok(result);
};
// Reject a peer continuation beyond the local bound before the
// caller callback can produce an effect or another request can be
// committed.
driver.begin_continuation()?;
let input_responses = respond(input_required)?;
let input_response_count = input_responses.len();
let retry_parameters = mrtr_retry_parameters(
original_parameters.clone(),
input_required,
input_responses,
)?;
driver.admit_input_responses(input_response_count)?;
parameters = retry_parameters;
}
}
/// Calls a tool and returns its negotiated, method-aware core result.
///
/// A modern session returns [`CoreResult::Final`] with a typed
/// [`FinalCoreResult::ToolsCall`] payload. An exact legacy session returns
/// [`CoreResult::Legacy`] with its unchanged `tools/call` result shape.
///
/// # Errors
///
/// Returns an error if the request fails or the peer result does not match
/// the selected era and `tools/call` response contract. A contradictory
/// core response terminates the connection.
pub fn call_tool_typed(
&mut self,
name: &str,
arguments: serde_json::Value,
) -> McpResult<CoreResult> {
self.ensure_initialized()?;
if self.session.selected_era() == Some(ProtocolEra::Modern2026)
&& self.reverse_request_handlers.has_modern_handlers()
{
let handlers = self.reverse_request_handlers.clone();
let cx = Cx::current().unwrap_or_else(|| self.cx.clone());
return self.call_tool_with_mrtr_retry(name, arguments, |input_required| {
handlers.respond_to_input_required(&cx, input_required)
});
}
let params = CallToolParams {
name: name.to_string(),
arguments: Some(arguments),
meta: None,
};
self.send_typed_core_request("tools/call", params)
}
/// Calls one tool under a request-local cancellation domain.
///
/// A cancellation observed before send makes no transport contact. One
/// observed after commit sends the selected-era cancellation control.
/// Installed modern reverse handlers are not followed on this path; use
/// [`Self::call_tool_typed`] or [`Self::call_tool_with_mrtr_retry`] when
/// the caller must resume `input_required`.
pub fn call_tool_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
name: &str,
arguments: serde_json::Value,
) -> McpResult<CoreResult> {
let params = CallToolParams {
name: name.to_string(),
arguments: Some(arguments),
meta: None,
};
let parameters = serde_json::to_value(params).map_err(|error| {
McpError::internal_error(format!(
"Client tools/call parameters could not serialize: {error}"
))
})?;
self.request_core_with_cancellation(cx, cancellation, "tools/call", parameters, |_| {})
}
/// Calls a tool and follows bounded final MRTR continuations with
/// caller-supplied responses.
///
/// Each continuation receives a fresh request ID and rebuilds from the
/// original parameters. Exact MCP 2024-11-05 results and final complete or
/// Tasks results return without invoking `respond`. The operation shares
/// one caller context, absolute deadline, continuation-round bound, and
/// total input-response bound.
pub fn call_tool_with_mrtr_retry<F>(
&mut self,
name: &str,
arguments: serde_json::Value,
respond: F,
) -> McpResult<CoreResult>
where
F: FnMut(&InputRequiredResult) -> McpResult<MrtrInputResponses>,
{
self.ensure_initialized()?;
if self.session.selected_era() == Some(ProtocolEra::Legacy2024) {
return self.call_tool_typed(name, arguments);
}
let original_parameters = serde_json::json!({
"name": name,
"arguments": arguments,
});
self.drive_mrtr_retry("tools/call", original_parameters, respond)
}
/// Calls a final tool with the official Tasks result surface enabled.
///
/// Bilateral empty-settings negotiation is proved from the retained
/// discovery response before a request ID is allocated. The request then
/// declares Tasks explicitly and returns the exact complete, task, or
/// input-required branch without legacy projection.
#[cfg(feature = "tasks")]
pub fn call_tool_final_outcome(
&mut self,
name: &str,
arguments: serde_json::Value,
) -> McpResult<FinalToolCallOutcome> {
self.require_modern_final_result_session("tools/call")?;
let discovery = self.server_discovery().ok_or_else(|| {
McpError::invalid_params(
"Modern Tasks requires the retained final server/discover response",
)
})?;
admit_final_tasks_result_discriminator(discovery, OFFICIAL_TASKS_RESULT_DISCRIMINATOR)?;
let params = CallToolParams {
name: name.to_owned(),
arguments: Some(arguments),
meta: None,
};
match self.send_typed_core_request_with_tasks("tools/call", params, true)? {
CoreResult::Final(FinalCoreResult::ToolsCall { result, .. }) => {
Ok(FinalToolCallOutcome::Complete(result))
}
CoreResult::Final(FinalCoreResult::ToolsCallTask { result }) => {
Ok(FinalToolCallOutcome::Task(result))
}
CoreResult::Final(FinalCoreResult::ToolsCallInputRequired { result, .. }) => {
Ok(FinalToolCallOutcome::InputRequired(result))
}
_ => Err(unexpected_convenience_result("tools/call")),
}
}
/// Calls a Tasks-capable final tool under a request-local cancellation
/// domain, preserving an optional downstream progress token on the
/// admitted upstream request.
#[cfg(feature = "tasks")]
pub fn call_tool_final_outcome_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
name: &str,
arguments: serde_json::Value,
progress_marker: Option<&fastmcp_protocol::ProgressMarker>,
inbound_identity: Option<&fastmcp_protocol::common_types::Implementation>,
inbound_log_level: Option<LoggingLevel>,
inbound_capabilities: Option<ClientCapabilities>,
) -> McpResult<FinalToolCallOutcome> {
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
self.require_modern_final_result_session("tools/call")?;
let discovery = self.server_discovery().ok_or_else(|| {
McpError::invalid_params(
"Modern Tasks requires the retained final server/discover response",
)
})?;
admit_final_tasks_result_discriminator(discovery, OFFICIAL_TASKS_RESULT_DISCRIMINATOR)?;
let mut parameters = self.with_final_tasks_client_capability(serde_json::json!({
"name": name,
"arguments": arguments,
}))?;
if let Some(marker) = progress_marker {
parameters["_meta"]["progressToken"] = serde_json::to_value(marker).map_err(|_| {
McpError::internal_error("Modern Tasks progress token could not be encoded")
})?;
}
if inbound_identity.is_some()
|| inbound_log_level.is_some()
|| inbound_capabilities.is_some()
{
let metadata = parameters
.get_mut("_meta")
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| {
McpError::internal_error("Modern Tasks tools/call omitted request metadata")
})?;
if let Some(identity) = inbound_identity {
metadata.insert(
FINAL_CLIENT_INFO_META_KEY.to_owned(),
serde_json::to_value(identity).map_err(|_| {
McpError::internal_error("Inbound client identity could not be encoded")
})?,
);
}
if let Some(level) = inbound_log_level {
metadata.insert(
FINAL_LOG_LEVEL_META_KEY.to_owned(),
serde_json::to_value(level).map_err(|_| {
McpError::internal_error("Inbound logLevel could not be encoded")
})?,
);
}
if let Some(capabilities) = inbound_capabilities {
overlay_inbound_core_client_capabilities_on_metadata(metadata, &capabilities)?;
}
}
let request = CoreRequest::decode(ProtocolEra::Modern2026, "tools/call", Some(¶meters))
.map_err(|_| {
McpError::invalid_params("Modern Tasks tools/call parameters are invalid")
})?;
let received =
if self.live_catalog_subscription.is_some() || self.multiplexed_executor.is_some() {
self.send_prepared_request_through_stdio_executor(
cx,
cancellation,
"tools/call",
parameters,
)?
} else {
self.send_prepared_request_with_request_cancellation(
cx,
cancellation,
"tools/call",
parameters,
RequestCancellationTerminalElection::FinalToolsCallTask {
request: Box::new(request.clone()),
},
|_| {},
)?
};
let (result, diagnostic) = decode_core_result_with_cache_ttl_from_source(
&request,
&received.result,
received.raw_result.as_deref(),
)
.map_err(|error| self.terminate_connection(error))?;
if let Some(diagnostic) = diagnostic {
self.retain_final_cache_ttl_diagnostic(diagnostic);
}
match result {
CoreResult::Final(FinalCoreResult::ToolsCall { result, .. }) => {
Ok(FinalToolCallOutcome::Complete(result))
}
CoreResult::Final(FinalCoreResult::ToolsCallTask { result }) => {
Ok(FinalToolCallOutcome::Task(result))
}
CoreResult::Final(FinalCoreResult::ToolsCallInputRequired { result, .. }) => {
Ok(FinalToolCallOutcome::InputRequired(result))
}
_ => Err(unexpected_convenience_result("tools/call")),
}
}
/// Calls a tool and returns its exact MCP 2026-07-28 result payload.
///
/// Unlike [`Self::call_tool`], this convenience API does not project final
/// content or `structuredContent` into the legacy result vocabulary.
///
/// # Errors
///
/// Returns an error before request mutation unless the negotiated session
/// is MCP 2026-07-28. It also returns an error when the request fails or
/// the peer contradicts the final `tools/call` result contract.
pub fn call_tool_final(
&mut self,
name: &str,
arguments: serde_json::Value,
) -> McpResult<FinalCallToolResult> {
self.require_modern_final_result_session("tools/call")?;
match self.call_tool_typed(name, arguments)? {
CoreResult::Final(FinalCoreResult::ToolsCall { result, .. }) => Ok(result.payload),
_ => Err(unexpected_convenience_result("tools/call")),
}
}
/// Calls one tool and admits request-scoped `notifications/progress` for
/// the supplied progress marker.
///
/// Drain those frames with [`Self::take_final_progress_notifications`].
pub fn call_tool_with_progress_marker(
&mut self,
name: &str,
arguments: serde_json::Value,
progress_marker: ProgressMarker,
) -> McpResult<CoreResult> {
self.ensure_initialized()?;
let params = CallToolParams {
name: name.to_string(),
arguments: Some(arguments),
meta: Some(RequestMeta {
progress_marker: Some(progress_marker),
}),
};
self.send_typed_core_request("tools/call", params)
}
/// Reads one resource and admits request-scoped `notifications/progress`
/// for the supplied progress marker.
///
/// Drain those frames with [`Self::take_final_progress_notifications`].
pub fn read_resource_with_progress_marker(
&mut self,
uri: &str,
progress_marker: ProgressMarker,
) -> McpResult<CoreResult> {
self.ensure_initialized()?;
let params = ReadResourceParams {
uri: uri.to_owned(),
meta: Some(RequestMeta {
progress_marker: Some(progress_marker),
}),
};
self.send_typed_core_request("resources/read", params)
}
/// Gets one prompt and admits request-scoped `notifications/progress` for
/// the supplied progress marker.
///
/// Drain those frames with [`Self::take_final_progress_notifications`].
pub fn get_prompt_with_progress_marker(
&mut self,
name: &str,
arguments: std::collections::HashMap<String, String>,
progress_marker: ProgressMarker,
) -> McpResult<CoreResult> {
self.ensure_initialized()?;
let params = GetPromptParams {
name: name.to_owned(),
arguments: (!arguments.is_empty()).then_some(arguments),
meta: Some(RequestMeta {
progress_marker: Some(progress_marker),
}),
};
self.send_typed_core_request("prompts/get", params)
}
/// Calls a tool and returns its exact MCP 2024-11-05 result payload.
///
/// This retains legacy result metadata and all schema-legal open members
/// without projecting them into the final result vocabulary.
///
/// # Errors
///
/// Returns an error before request mutation unless the negotiated session
/// is MCP 2024-11-05. It also returns an error when the request fails or
/// the peer contradicts the legacy `tools/call` result contract.
pub fn call_tool_legacy(
&mut self,
name: &str,
arguments: serde_json::Value,
) -> McpResult<CallToolResult> {
self.require_legacy_exact_result_session("tools/call")?;
match self.call_tool_typed(name, arguments)? {
CoreResult::Legacy(LegacyCoreResult::ToolsCall(result)) => Ok(result),
_ => Err(unexpected_convenience_result("tools/call")),
}
}
/// Calls a tool with the given arguments.
///
/// # Errors
///
/// Returns an error if the tool call fails.
pub fn call_tool(
&mut self,
name: &str,
arguments: serde_json::Value,
) -> McpResult<Vec<LegacyContent>> {
self.ensure_initialized()?;
let result = convenience_tool_call(self.call_tool_typed(name, arguments)?)?;
if result.is_error {
// Extract error message from content if available
let error_msg = result
.content
.first()
.and_then(|c| match c {
LegacyContent::Text { text, .. } => Some(text.clone()),
_ => None,
})
.unwrap_or_else(|| "Tool execution failed".to_string());
return Err(McpError::tool_error(error_msg));
}
Ok(result.content)
}
/// Calls a tool with progress callback support.
///
/// This method allows you to receive progress notifications during tool execution.
/// The callback is invoked for each progress notification received from the server.
///
/// # Arguments
///
/// * `name` - The tool name to call
/// * `arguments` - The tool arguments as JSON
/// * `on_progress` - Callback invoked for each progress notification
///
/// # Errors
///
/// Returns an error if the tool call fails.
pub fn call_tool_with_progress(
&mut self,
name: &str,
arguments: serde_json::Value,
on_progress: ProgressCallback<'_>,
) -> McpResult<Vec<LegacyContent>> {
self.ensure_initialized()?;
// Validate before allocating the ID that is also exposed as the
// progress token. The inner request path validates again immediately
// before registration so it remains safe when called directly.
let timeout_policy = self.timeout_policy;
timeout_policy.validate()?;
// Generate a unique request ID and reuse it as the progress token.
let request_id = self.next_request_id()?;
let progress_marker = ProgressMarker::Number(JsonInteger::from(
i64::try_from(request_id).expect("request ID allocator enforces the i64 bound"),
));
let params = CallToolParams {
name: name.to_string(),
arguments: Some(arguments),
meta: Some(RequestMeta {
progress_marker: Some(progress_marker.clone()),
}),
};
let result = convenience_tool_call(self.send_typed_core_request_with_progress(
"tools/call",
params,
request_id,
&progress_marker,
on_progress,
)?)?;
if result.is_error {
// Extract error message from content if available
let error_msg = result
.content
.first()
.and_then(|c| match c {
LegacyContent::Text { text, .. } => Some(text.clone()),
_ => None,
})
.unwrap_or_else(|| "Tool execution failed".to_string());
return Err(McpError::tool_error(error_msg));
}
Ok(result.content)
}
/// Sends a request and waits for response, handling progress notifications.
fn send_request_with_progress<P: serde::Serialize, R: serde::de::DeserializeOwned>(
&mut self,
method: &str,
params: P,
request_id: u64,
expected_marker: &ProgressMarker,
on_progress: ProgressCallback<'_>,
) -> McpResult<R> {
// Validate configuration before serialization, waiter registration, or
// protocol commitment. The caller already owns `request_id`, so this
// specifically prevents an invalid duration from creating live state.
let timeout_policy = self.timeout_policy;
timeout_policy.validate()?;
let params_value = serde_json::to_value(params)
.map_err(|e| McpError::internal_error(format!("Failed to serialize params: {e}")))?;
let params_value = self.prepare_request_parameters(params_value)?;
let core_request = self.prepared_core_request(method, ¶ms_value)?;
let received = self.send_prepared_request_with_progress(
method,
params_value,
request_id,
expected_marker,
on_progress,
)?;
if let Some(core_request) = core_request
&& let Err(error) = decode_core_result_from_source(
&core_request,
&received.result,
received.raw_result.as_deref(),
)
{
return Err(self.terminate_connection(error));
}
decode_response_payload(received.result)
}
/// Sends one supported core request with progress handling and retains its
/// selected-era result.
fn send_typed_core_request_with_progress<P: serde::Serialize>(
&mut self,
method: &str,
params: P,
request_id: u64,
expected_marker: &ProgressMarker,
on_progress: ProgressCallback<'_>,
) -> McpResult<CoreResult> {
let timeout_policy = self.timeout_policy;
timeout_policy.validate()?;
let params_value = serde_json::to_value(params)
.map_err(|e| McpError::internal_error(format!("Failed to serialize params: {e}")))?;
let params_value = self.prepare_request_parameters(params_value)?;
let core_request = self
.prepared_core_request(method, ¶ms_value)?
.ok_or_else(|| {
McpError::invalid_params(
"Method is not a supported core request in the negotiated era",
)
})?;
let params_value = core_request
.encode_params()
.map_err(|_| {
McpError::invalid_params(
"Client core request could not be encoded in the negotiated protocol era",
)
})?
.ok_or_else(|| {
McpError::invalid_params(
"Method has no parameter object in the negotiated protocol era",
)
})?;
let received = self.send_prepared_request_with_progress(
method,
params_value,
request_id,
expected_marker,
on_progress,
)?;
decode_core_result_from_source(
&core_request,
&received.result,
received.raw_result.as_deref(),
)
.map_err(|error| self.terminate_connection(error))
}
/// Sends an already-prepared request and waits for its response while
/// routing matching progress notifications.
fn send_prepared_request_with_progress(
&mut self,
method: &str,
params_value: serde_json::Value,
request_id: u64,
expected_marker: &ProgressMarker,
on_progress: ProgressCallback<'_>,
) -> McpResult<ReceivedPreparedResult> {
let timeout_policy = self.timeout_policy;
timeout_policy.validate()?;
let request_id = RequestId::Number(
i64::try_from(request_id).expect("request ID allocator enforces the i64 bound"),
);
let request = JsonRpcRequest::new(method, Some(params_value), request_id.clone());
let waiter = self.responses.register(request_id.clone())?;
if let Err(error) = self.send_to_server(&JsonRpcMessage::Request(request)) {
let error = self.record_send_failure(Some(&request_id), error);
return Err(error);
}
let committed_at = Instant::now();
let deadlines = match RequestDeadlines::start_at(timeout_policy, committed_at) {
Ok(deadlines) => deadlines,
Err(error) => {
return Err(self.finish_committed_request_locally(&request_id, error));
}
};
// Receive response, handling progress notifications
let ReceivedJsonRpcResponse {
mut response,
raw_result,
} = self.recv_response_with_progress(
waiter,
expected_marker,
on_progress,
timeout_policy,
deadlines,
)?;
let receipt = Instant::now();
// Check for error response
if let Some(error) = response.error.take() {
return Err(json_rpc_error_to_mcp(error));
}
// Parse result
let result = response
.result
.take()
.ok_or_else(|| McpError::internal_error("No result in response"))?;
Ok(ReceivedPreparedResult {
result,
raw_result,
receipt,
})
}
/// Receives a response from the transport, handling progress notifications.
fn recv_response_with_progress(
&mut self,
mut waiter: ResponseWaiter,
expected_marker: &ProgressMarker,
on_progress: ProgressCallback<'_>,
timeout_policy: RequestTimeoutPolicy,
mut deadlines: RequestDeadlines,
) -> McpResult<ReceivedJsonRpcResponse> {
let cx = self.cx.clone();
let expected_id = waiter.id.clone();
let mut last_progress = None;
let mut last_final_progress = None;
loop {
if let Some(response) = waiter.try_response()? {
debug_assert_eq!(response.id.as_ref(), Some(&expected_id));
return Ok(response);
}
if let Some(kind) = deadlines.expired_at(Instant::now()) {
return Err(self.timeout_committed_request(&expected_id, kind));
}
let receive_deadline = self.reverse_callback_poll_deadline(deadlines.next());
let (frame, received_at) = match self.recv_next_child_frame(&cx, Some(receive_deadline))
{
Ok(received) => received,
Err(TransportError::ReceiveDeadlineExceeded) => {
if receive_deadline < deadlines.next() && !self.transport_is_closed() {
self.drain_completed_reverse_callbacks()
.map_err(|error| self.terminate_connection(error))?;
continue;
}
let kind = deadlines
.expired_at(Instant::now())
.unwrap_or_else(|| deadlines.next_kind());
if self.transport_is_closed() {
return Err(self.finish_partial_frame_timeout(&expected_id, kind));
}
return Err(self.timeout_committed_request(&expected_id, kind));
}
Err(TransportError::Timeout) if !self.transport_is_closed() => {
return Err(self.finish_open_context_interruption(
&expected_id,
McpError::internal_error("Request timed out"),
));
}
Err(TransportError::Cancelled) if !self.transport_is_closed() => {
return Err(self.finish_open_context_interruption(
&expected_id,
McpError::request_cancelled(),
));
}
Err(error) => {
let error = transport_error_to_mcp(error);
return Err(self.terminate_connection(error));
}
};
if let Some(kind) = deadlines.expired_at(received_at) {
return Err(self.finish_timeout_after_complete_frame(&expected_id, frame, kind));
}
if let Err(error) = validate_inbound_typed_message(frame.message()) {
return Err(self.terminate_connection(error));
}
if matches!(frame.message(), JsonRpcMessage::Response(_)) {
let route = self
.route_received_response(frame)
.map_err(|error| self.terminate_connection(error))?;
if matches!(
route,
ResponseRoute::InvalidEnvelope
| ResponseRoute::MissingId
| ResponseRoute::ConnectionClosed
) {
let error = self.responses.terminal_error().unwrap_or_else(|| {
McpError::internal_error("Client response correlation failed")
});
return Err(self.terminate_connection(error));
}
} else {
let JsonRpcMessage::Request(request) = frame.message() else {
unreachable!("a JSON-RPC message is either a request or response")
};
if let Some(executor) = self.multiplexed_executor.clone()
&& executor.owns_selected_stdio_notification(request)
{
executor
.drive_frame(&cx, frame)
.map_err(|error| self.terminate_connection(error))?;
continue;
}
if self.cancel_legacy_reverse_callback(request) {
continue;
}
match self.retain_modern_server_notification(&frame) {
Ok(Some(ModernServerNotification::Progress(progress))) => {
if last_final_progress
.as_ref()
.is_none_or(|last| progress.progress.cmp(last).is_gt())
&& progress.progress_token == *expected_marker
{
last_final_progress = Some(progress.progress.clone());
let callback_progress = progress
.progress
.as_str()
.parse::<f64>()
.ok()
.filter(|value| value.is_finite());
let callback_total =
progress.total.as_ref().map_or(Some(None), |total| {
total
.as_str()
.parse::<f64>()
.ok()
.filter(|value| value.is_finite())
.map(Some)
});
if let (Some(callback_progress), Some(callback_total)) =
(callback_progress, callback_total)
{
if invoke_tool_progress_callback(
&mut *on_progress,
callback_progress,
callback_total,
progress.message.as_deref(),
)
.is_err()
{
let error =
McpError::internal_error(PROGRESS_CALLBACK_PANIC_ERROR);
return Err(
self.finish_committed_request_locally(&expected_id, error)
);
}
}
if timeout_policy.reset_idle_on_matching_progress
&& let Err(error) = deadlines.reset_idle_at(received_at)
{
return Err(
self.finish_committed_request_locally(&expected_id, error)
);
}
}
continue;
}
Ok(Some(_)) => continue,
Ok(None) => {}
Err(error) => return Err(self.terminate_connection(error)),
}
if let Some(response) = self.server_request_response(request) {
if let Err(error) = self.send_server_response_during_receive(response) {
return Err(self.terminate_connection(error));
}
continue;
}
if server_notification_kind(request) == Some(ServerNotificationKind::Progress) {
if let Some(params) = request.params.as_ref()
&& let Some(progress) = parse_valid_client_progress(params, last_progress)
&& progress.marker == *expected_marker
{
if invoke_tool_progress_callback(
&mut *on_progress,
progress.progress,
progress.total,
progress.message.as_deref(),
)
.is_err()
{
let error = McpError::internal_error(PROGRESS_CALLBACK_PANIC_ERROR);
return Err(self.finish_committed_request_locally(&expected_id, error));
}
last_progress = Some(progress.progress);
if timeout_policy.reset_idle_on_matching_progress
&& let Err(error) = deadlines.reset_idle_at(received_at)
{
return Err(self.finish_committed_request_locally(&expected_id, error));
}
}
} else if server_notification_kind(request)
== Some(ServerNotificationKind::LogMessage)
{
if let Some(params) = request.params.as_ref() {
if let Ok(message) =
serde_json::from_value::<LogMessageParams>(params.clone())
{
self.emit_log_message(message);
}
}
}
// Continue waiting for actual response
}
}
}
fn emit_log_message(&self, message: LogMessageParams) {
let level = match message.level {
LogLevel::Debug => log::Level::Debug,
LogLevel::Info | LogLevel::Notice => log::Level::Info,
LogLevel::Warning => log::Level::Warn,
LogLevel::Error | LogLevel::Critical | LogLevel::Alert | LogLevel::Emergency => {
log::Level::Error
}
};
let metadata = remote_log_metadata(&message);
log::log!(target: REMOTE_LOG_TARGET, level, "{metadata}");
}
/// Lists one page of resources and returns its negotiated core result.
///
/// The caller supplies the opaque peer cursor, if any. A modern session
/// returns [`CoreResult::Final`] with [`FinalCoreResult::ResourcesList`];
/// an exact legacy session returns [`CoreResult::Legacy`].
///
/// # Errors
///
/// Returns an error if the request fails or its selected-era result
/// contract is contradicted. A contradictory core result terminates the
/// connection.
pub fn list_resources_typed(&mut self, cursor: Option<&str>) -> McpResult<CoreResult> {
self.list_resources_typed_with_params(ListResourcesParams {
cursor: cursor.map(ToOwned::to_owned),
..ListResourcesParams::default()
})
}
/// Lists one page of resources with explicit include/exclude tag filters.
///
/// The tag filters are part of the modern list-cache key so a filtered
/// page cannot be served from an unfiltered one.
pub fn list_resources_typed_with_params(
&mut self,
params: ListResourcesParams,
) -> McpResult<CoreResult> {
self.ensure_initialized()?;
let cursor = params.cursor.clone();
let semantic_parameters = list_resources_semantic_parameters(¶ms);
self.cached_final_core_request(
"resources/list",
semantic_parameters,
cursor.as_deref(),
FinalCacheResultSet::Resources,
move |client| client.send_typed_core_request("resources/list", params),
)
}
/// Lists available resources.
///
/// # Errors
///
/// Returns an error if the request fails.
pub fn list_resources(&mut self) -> McpResult<Vec<Resource>> {
self.list_resources_with_params(ListResourcesParams::default())
}
/// Follows peer cursors for one tag-filtered resources/list query.
pub fn list_resources_with_params(
&mut self,
params: ListResourcesParams,
) -> McpResult<Vec<Resource>> {
self.ensure_initialized()?;
let include_tags = params.include_tags.clone();
let exclude_tags = params.exclude_tags.clone();
let mut restarts = 0;
'rebuild: loop {
let mut all = Vec::new();
let mut cursor: Option<String> = params.cursor.clone();
let mut budget = PaginationBudget::new();
let mut baseline = None;
loop {
budget.begin_page()?;
let page_params = ListResourcesParams {
cursor: cursor.clone(),
include_tags: include_tags.clone(),
exclude_tags: exclude_tags.clone(),
};
let (resources, next_cursor) = convenience_resources_page(
self.list_resources_typed_with_params(page_params)?,
)?;
if self.final_list_restart_needed(&FinalCacheResultSet::Resources, &mut baseline) {
restarts += 1;
if restarts > 1 {
return Err(McpError::invalid_request(
FINAL_CACHE_LIST_RESTART_LIMIT_ERROR,
));
}
continue 'rebuild;
}
budget.account_page(&resources)?;
all.extend(resources);
cursor = budget.admit_next_cursor(next_cursor)?;
if cursor.is_none() {
return Ok(all);
}
}
}
}
/// Acquires at most one bounded page of resources.
///
/// # Errors
///
/// Returns an error if the caller's limits or cursor are invalid, the
/// request fails, or the peer returns an oversized or non-advancing cursor.
pub fn list_resources_page(
&mut self,
cursor: Option<&str>,
limits: ListPageLimits,
) -> McpResult<BoundedListPage<Resource>> {
self.list_resources_page_with_params(
ListResourcesParams {
cursor: cursor.map(ToOwned::to_owned),
..ListResourcesParams::default()
},
limits,
)
}
/// Acquires at most one bounded, tag-filtered resources page.
pub fn list_resources_page_with_params(
&mut self,
params: ListResourcesParams,
limits: ListPageLimits,
) -> McpResult<BoundedListPage<Resource>> {
let request_cursor = params.cursor.clone();
let cursor_parameter = validate_list_page_request(request_cursor.as_deref(), limits)?;
self.ensure_initialized()?;
let params = ListResourcesParams {
cursor: cursor_parameter,
..params
};
let (resources, next_cursor) =
convenience_resources_page(self.list_resources_typed_with_params(params)?)?;
bounded_list_page(resources, request_cursor.as_deref(), next_cursor, limits)
}
/// Lists one page of resource templates and returns its negotiated core
/// result.
///
/// The caller supplies the opaque peer cursor, if any. A modern session
/// returns [`CoreResult::Final`] with
/// [`FinalCoreResult::ResourceTemplatesList`]; an exact legacy session
/// returns [`CoreResult::Legacy`].
///
/// # Errors
///
/// Returns an error if the request fails or its selected-era result
/// contract is contradicted. A contradictory core result terminates the
/// connection.
pub fn list_resource_templates_typed(&mut self, cursor: Option<&str>) -> McpResult<CoreResult> {
self.list_resource_templates_typed_with_params(ListResourceTemplatesParams {
cursor: cursor.map(ToOwned::to_owned),
..ListResourceTemplatesParams::default()
})
}
/// Lists one page of resource templates with explicit include/exclude tag filters.
pub fn list_resource_templates_typed_with_params(
&mut self,
params: ListResourceTemplatesParams,
) -> McpResult<CoreResult> {
self.ensure_initialized()?;
let cursor = params.cursor.clone();
let semantic_parameters = list_resource_templates_semantic_parameters(¶ms);
self.cached_final_core_request(
"resources/templates/list",
semantic_parameters,
cursor.as_deref(),
FinalCacheResultSet::ResourceTemplates,
move |client| client.send_typed_core_request("resources/templates/list", params),
)
}
/// Lists available resource templates.
///
/// # Errors
///
/// Returns an error if the request fails.
pub fn list_resource_templates(&mut self) -> McpResult<Vec<ResourceTemplate>> {
self.list_resource_templates_with_params(ListResourceTemplatesParams::default())
}
/// Follows peer cursors for one tag-filtered resources/templates/list query.
pub fn list_resource_templates_with_params(
&mut self,
params: ListResourceTemplatesParams,
) -> McpResult<Vec<ResourceTemplate>> {
self.ensure_initialized()?;
let include_tags = params.include_tags.clone();
let exclude_tags = params.exclude_tags.clone();
let mut restarts = 0;
'rebuild: loop {
let mut all = Vec::new();
let mut cursor: Option<String> = params.cursor.clone();
let mut budget = PaginationBudget::new();
let mut baseline = None;
loop {
budget.begin_page()?;
let page_params = ListResourceTemplatesParams {
cursor: cursor.clone(),
include_tags: include_tags.clone(),
exclude_tags: exclude_tags.clone(),
};
let (resource_templates, next_cursor) = convenience_resource_templates_page(
self.list_resource_templates_typed_with_params(page_params)?,
)?;
if self.final_list_restart_needed(
&FinalCacheResultSet::ResourceTemplates,
&mut baseline,
) {
restarts += 1;
if restarts > 1 {
return Err(McpError::invalid_request(
FINAL_CACHE_LIST_RESTART_LIMIT_ERROR,
));
}
continue 'rebuild;
}
budget.account_page(&resource_templates)?;
all.extend(resource_templates);
cursor = budget.admit_next_cursor(next_cursor)?;
if cursor.is_none() {
return Ok(all);
}
}
}
}
/// Acquires at most one bounded page of resource templates.
///
/// # Errors
///
/// Returns an error if the caller's limits or cursor are invalid, the
/// request fails, or the peer returns an oversized or non-advancing cursor.
pub fn list_resource_templates_page(
&mut self,
cursor: Option<&str>,
limits: ListPageLimits,
) -> McpResult<BoundedListPage<ResourceTemplate>> {
self.list_resource_templates_page_with_params(
ListResourceTemplatesParams {
cursor: cursor.map(ToOwned::to_owned),
..ListResourceTemplatesParams::default()
},
limits,
)
}
/// Acquires at most one bounded, tag-filtered resource-templates page.
pub fn list_resource_templates_page_with_params(
&mut self,
params: ListResourceTemplatesParams,
limits: ListPageLimits,
) -> McpResult<BoundedListPage<ResourceTemplate>> {
let request_cursor = params.cursor.clone();
let cursor_parameter = validate_list_page_request(request_cursor.as_deref(), limits)?;
self.ensure_initialized()?;
let params = ListResourceTemplatesParams {
cursor: cursor_parameter,
..params
};
let (resource_templates, next_cursor) = convenience_resource_templates_page(
self.list_resource_templates_typed_with_params(params)?,
)?;
bounded_list_page(
resource_templates,
request_cursor.as_deref(),
next_cursor,
limits,
)
}
/// Configures the selected protocol era's log level behavior.
///
/// A modern MCP 2026-07-28 session stores the complete RFC 5424 level and
/// adds it as `io.modelcontextprotocol/logLevel` metadata to every later
/// request. It never sends `logging/setLevel`. An exact 2024-11-05 session
/// sends the historical RPC with the same RFC 5424 severity.
///
/// # Errors
///
/// Returns an error if the peer rejects its historical acknowledgement.
pub fn set_log_level_typed(&mut self, level: LoggingLevel) -> McpResult<()> {
self.ensure_initialized()?;
match self.session.selected_era() {
Some(ProtocolEra::Modern2026) => {
self.final_log_level = Some(level);
Ok(())
}
Some(ProtocolEra::Legacy2024) => {
let level = legacy_log_level(level);
let params = SetLogLevelParams { level };
let _: serde_json::Value = self.send_request("logging/setLevel", params)?;
Ok(())
}
None => Err(McpError::internal_error(
"Client has no negotiated protocol era for logging configuration",
)),
}
}
/// Configures one of the RFC 5424 severities supported by both protocol eras.
///
/// Modern sessions use later request metadata; exact legacy sessions send
/// `logging/setLevel` unchanged.
pub fn set_log_level(&mut self, level: LogLevel) -> McpResult<()> {
self.set_log_level_typed(final_log_level(level))
}
/// Reads a resource and returns its negotiated, method-aware core result.
///
/// A modern session returns [`CoreResult::Final`] with
/// [`FinalCoreResult::ResourcesRead`]. An exact legacy session returns
/// [`CoreResult::Legacy`] with its unchanged resource result shape.
///
/// # Errors
///
/// Returns an error if the request fails or its selected-era result
/// contract is contradicted. A contradictory core result terminates the
/// connection.
pub fn read_resource_typed(&mut self, uri: &str) -> McpResult<CoreResult> {
self.ensure_initialized()?;
if self.session.selected_era() == Some(ProtocolEra::Modern2026)
&& self.reverse_request_handlers.has_modern_handlers()
{
let handlers = self.reverse_request_handlers.clone();
let cx = Cx::current().unwrap_or_else(|| self.cx.clone());
return self.read_resource_with_mrtr_retry(uri, |input_required| {
handlers.respond_to_input_required(&cx, input_required)
});
}
let uri = uri.to_owned();
let params = ReadResourceParams {
uri: uri.clone(),
meta: None,
};
self.cached_final_core_request(
"resources/read",
serde_json::json!({"uri": uri}),
None,
FinalCacheResultSet::Resource(params.uri.clone()),
move |client| client.send_typed_core_request("resources/read", params),
)
}
/// Reads one resource under a request-local cancellation domain.
pub fn read_resource_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
uri: &str,
) -> McpResult<CoreResult> {
let parameters = serde_json::json!({ "uri": uri });
self.request_core_with_cancellation(cx, cancellation, "resources/read", parameters, |_| {})
}
/// Reads a resource and follows bounded final MRTR continuations with
/// caller-supplied responses.
///
/// See [`Self::call_tool_with_mrtr_retry`] for the shared bounded
/// continuation and terminal-result behavior.
pub fn read_resource_with_mrtr_retry<F>(
&mut self,
uri: &str,
respond: F,
) -> McpResult<CoreResult>
where
F: FnMut(&InputRequiredResult) -> McpResult<MrtrInputResponses>,
{
self.ensure_initialized()?;
if self.session.selected_era() == Some(ProtocolEra::Legacy2024) {
return self.read_resource_typed(uri);
}
let original_parameters = serde_json::json!({ "uri": uri });
self.drive_mrtr_retry("resources/read", original_parameters, respond)
}
/// Reads a resource and returns its exact MCP 2026-07-28 result payload.
///
/// This retains final cache directives and resource open fields without a
/// projection into [`LegacyResourceContent`].
///
/// # Errors
///
/// Returns an error before request mutation unless the negotiated session
/// is MCP 2026-07-28. It also returns an error when the request fails or
/// the peer contradicts the final `resources/read` result contract.
pub fn read_resource_final(&mut self, uri: &str) -> McpResult<FinalReadResourceResult> {
self.require_modern_final_result_session("resources/read")?;
match self.read_resource_typed(uri)? {
CoreResult::Final(FinalCoreResult::ResourcesRead { result, .. }) => Ok(result.payload),
_ => Err(unexpected_convenience_result("resources/read")),
}
}
/// Reads a resource and returns its exact MCP 2024-11-05 result payload.
///
/// This retains legacy result metadata and all schema-legal open members
/// without projection into the final resource result vocabulary.
///
/// # Errors
///
/// Returns an error before request mutation unless the negotiated session
/// is MCP 2024-11-05. It also returns an error when the request fails or
/// the peer contradicts the legacy `resources/read` result contract.
pub fn read_resource_legacy(&mut self, uri: &str) -> McpResult<ReadResourceResult> {
self.require_legacy_exact_result_session("resources/read")?;
match self.read_resource_typed(uri)? {
CoreResult::Legacy(LegacyCoreResult::ResourcesRead(result)) => Ok(result),
_ => Err(unexpected_convenience_result("resources/read")),
}
}
/// Subscribes to one resource through the exact MCP 2024-11-05 method.
///
/// This legacy method has no final-era equivalent. A modern session is
/// rejected before serializing parameters, allocating an ID, or sending.
pub fn subscribe_resource_legacy(&mut self, uri: &str) -> McpResult<()> {
self.require_legacy_exact_result_session("resources/subscribe")?;
let _: serde_json::Value = self.send_request(
"resources/subscribe",
SubscribeResourceParams {
uri: uri.to_owned(),
},
)?;
Ok(())
}
/// Ends one resource subscription through the exact MCP 2024-11-05 method.
///
/// This legacy method has no final-era equivalent. A modern session is
/// rejected before serializing parameters, allocating an ID, or sending.
pub fn unsubscribe_resource_legacy(&mut self, uri: &str) -> McpResult<()> {
self.require_legacy_exact_result_session("resources/unsubscribe")?;
let _: serde_json::Value = self.send_request(
"resources/unsubscribe",
UnsubscribeResourceParams {
uri: uri.to_owned(),
},
)?;
Ok(())
}
/// Reads a resource by URI.
///
/// # Errors
///
/// Returns an error if the resource cannot be read.
pub fn read_resource(&mut self, uri: &str) -> McpResult<Vec<LegacyResourceContent>> {
self.ensure_initialized()?;
convenience_resource_read(self.read_resource_typed(uri)?)
}
/// Lists one page of prompts and returns its negotiated core result.
///
/// The caller supplies the opaque peer cursor, if any. A modern session
/// returns [`CoreResult::Final`] with [`FinalCoreResult::PromptsList`]; an
/// exact legacy session returns [`CoreResult::Legacy`].
///
/// # Errors
///
/// Returns an error if the request fails or its selected-era result
/// contract is contradicted. A contradictory core result terminates the
/// connection.
pub fn list_prompts_typed(&mut self, cursor: Option<&str>) -> McpResult<CoreResult> {
self.list_prompts_typed_with_params(ListPromptsParams {
cursor: cursor.map(ToOwned::to_owned),
..ListPromptsParams::default()
})
}
/// Lists one page of prompts with explicit include/exclude tag filters.
///
/// The tag filters are part of the modern list-cache key so a filtered
/// page cannot be served from an unfiltered one.
pub fn list_prompts_typed_with_params(
&mut self,
params: ListPromptsParams,
) -> McpResult<CoreResult> {
self.ensure_initialized()?;
let cursor = params.cursor.clone();
let semantic_parameters = list_prompts_semantic_parameters(¶ms);
self.cached_final_core_request(
"prompts/list",
semantic_parameters,
cursor.as_deref(),
FinalCacheResultSet::Prompts,
move |client| client.send_typed_core_request("prompts/list", params),
)
}
/// Lists available prompts.
///
/// # Errors
///
/// Returns an error if the request fails.
pub fn list_prompts(&mut self) -> McpResult<Vec<Prompt>> {
self.list_prompts_with_params(ListPromptsParams::default())
}
/// Follows peer cursors for one tag-filtered prompts/list query.
pub fn list_prompts_with_params(
&mut self,
params: ListPromptsParams,
) -> McpResult<Vec<Prompt>> {
self.ensure_initialized()?;
let include_tags = params.include_tags.clone();
let exclude_tags = params.exclude_tags.clone();
let mut restarts = 0;
'rebuild: loop {
let mut all = Vec::new();
let mut cursor: Option<String> = params.cursor.clone();
let mut budget = PaginationBudget::new();
let mut baseline = None;
loop {
budget.begin_page()?;
let page_params = ListPromptsParams {
cursor: cursor.clone(),
include_tags: include_tags.clone(),
exclude_tags: exclude_tags.clone(),
};
let (prompts, next_cursor) =
convenience_prompts_page(self.list_prompts_typed_with_params(page_params)?)?;
if self.final_list_restart_needed(&FinalCacheResultSet::Prompts, &mut baseline) {
restarts += 1;
if restarts > 1 {
return Err(McpError::invalid_request(
FINAL_CACHE_LIST_RESTART_LIMIT_ERROR,
));
}
continue 'rebuild;
}
budget.account_page(&prompts)?;
all.extend(prompts);
cursor = budget.admit_next_cursor(next_cursor)?;
if cursor.is_none() {
return Ok(all);
}
}
}
}
/// Acquires at most one bounded page of prompts.
///
/// # Errors
///
/// Returns an error if the caller's limits or cursor are invalid, the
/// request fails, or the peer returns an oversized or non-advancing cursor.
pub fn list_prompts_page(
&mut self,
cursor: Option<&str>,
limits: ListPageLimits,
) -> McpResult<BoundedListPage<Prompt>> {
self.list_prompts_page_with_params(
ListPromptsParams {
cursor: cursor.map(ToOwned::to_owned),
..ListPromptsParams::default()
},
limits,
)
}
/// Acquires at most one bounded, tag-filtered prompts page.
pub fn list_prompts_page_with_params(
&mut self,
params: ListPromptsParams,
limits: ListPageLimits,
) -> McpResult<BoundedListPage<Prompt>> {
let request_cursor = params.cursor.clone();
let cursor_parameter = validate_list_page_request(request_cursor.as_deref(), limits)?;
self.ensure_initialized()?;
let params = ListPromptsParams {
cursor: cursor_parameter,
..params
};
let (prompts, next_cursor) =
convenience_prompts_page(self.list_prompts_typed_with_params(params)?)?;
bounded_list_page(prompts, request_cursor.as_deref(), next_cursor, limits)
}
/// Gets a prompt and returns its negotiated, method-aware core result.
///
/// A modern session returns [`CoreResult::Final`] with
/// [`FinalCoreResult::PromptsGet`]. An exact legacy session returns
/// [`CoreResult::Legacy`] with its unchanged prompt result shape.
///
/// # Errors
///
/// Returns an error if the request fails or its selected-era result
/// contract is contradicted. A contradictory core result terminates the
/// connection.
pub fn get_prompt_typed(
&mut self,
name: &str,
arguments: std::collections::HashMap<String, String>,
) -> McpResult<CoreResult> {
self.ensure_initialized()?;
if self.session.selected_era() == Some(ProtocolEra::Modern2026)
&& self.reverse_request_handlers.has_modern_handlers()
{
let handlers = self.reverse_request_handlers.clone();
let cx = Cx::current().unwrap_or_else(|| self.cx.clone());
return self.get_prompt_with_mrtr_retry(name, arguments, |input_required| {
handlers.respond_to_input_required(&cx, input_required)
});
}
let params = GetPromptParams {
name: name.to_owned(),
arguments: (!arguments.is_empty()).then_some(arguments),
meta: None,
};
self.send_typed_core_request("prompts/get", params)
}
/// Gets one prompt under a request-local cancellation domain.
pub fn get_prompt_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
name: &str,
arguments: std::collections::HashMap<String, String>,
) -> McpResult<CoreResult> {
let mut parameters = serde_json::json!({ "name": name });
if !arguments.is_empty() {
parameters["arguments"] = serde_json::to_value(&arguments).map_err(|error| {
McpError::internal_error(format!(
"Client prompts/get arguments could not serialize: {error}"
))
})?;
}
self.request_core_with_cancellation(cx, cancellation, "prompts/get", parameters, |_| {})
}
/// Gets a prompt and follows bounded final MRTR continuations with
/// caller-supplied responses.
///
/// See [`Self::call_tool_with_mrtr_retry`] for the shared bounded
/// continuation and terminal-result behavior.
pub fn get_prompt_with_mrtr_retry<F>(
&mut self,
name: &str,
arguments: std::collections::HashMap<String, String>,
respond: F,
) -> McpResult<CoreResult>
where
F: FnMut(&InputRequiredResult) -> McpResult<MrtrInputResponses>,
{
self.ensure_initialized()?;
if self.session.selected_era() == Some(ProtocolEra::Legacy2024) {
return self.get_prompt_typed(name, arguments);
}
let mut retry_parameters = serde_json::json!({ "name": name });
if !arguments.is_empty() {
let parameters = retry_parameters.as_object_mut().ok_or_else(|| {
McpError::internal_error("MRTR prompt parameters must remain an object")
})?;
parameters.insert(
"arguments".to_owned(),
serde_json::to_value(&arguments).map_err(|error| {
McpError::internal_error(format!(
"MRTR prompt arguments could not serialize: {error}"
))
})?,
);
}
self.drive_mrtr_retry("prompts/get", retry_parameters, respond)
}
/// Gets a prompt and returns its exact MCP 2026-07-28 result payload.
///
/// This retains the final prompt description, final content vocabulary,
/// and open fields without a projection into [`LegacyPromptMessage`].
///
/// # Errors
///
/// Returns an error before request mutation unless the negotiated session
/// is MCP 2026-07-28. It also returns an error when the request fails or
/// the peer contradicts the final `prompts/get` result contract.
pub fn get_prompt_final(
&mut self,
name: &str,
arguments: std::collections::HashMap<String, String>,
) -> McpResult<FinalGetPromptResult> {
self.require_modern_final_result_session("prompts/get")?;
match self.get_prompt_typed(name, arguments)? {
CoreResult::Final(FinalCoreResult::PromptsGet { result, .. }) => Ok(result.payload),
_ => Err(unexpected_convenience_result("prompts/get")),
}
}
/// Gets a prompt and returns its exact MCP 2024-11-05 result payload.
///
/// This retains legacy descriptions, result metadata, and all
/// schema-legal open members without projection into the final prompt
/// result vocabulary.
///
/// # Errors
///
/// Returns an error before request mutation unless the negotiated session
/// is MCP 2024-11-05. It also returns an error when the request fails or
/// the peer contradicts the legacy `prompts/get` result contract.
pub fn get_prompt_legacy(
&mut self,
name: &str,
arguments: std::collections::HashMap<String, String>,
) -> McpResult<GetPromptResult> {
self.require_legacy_exact_result_session("prompts/get")?;
match self.get_prompt_typed(name, arguments)? {
CoreResult::Legacy(LegacyCoreResult::PromptsGet(result)) => Ok(result),
_ => Err(unexpected_convenience_result("prompts/get")),
}
}
/// Gets a prompt with the given arguments.
///
/// # Errors
///
/// Returns an error if the prompt cannot be retrieved.
pub fn get_prompt(
&mut self,
name: &str,
arguments: std::collections::HashMap<String, String>,
) -> McpResult<Vec<LegacyPromptMessage>> {
self.ensure_initialized()?;
convenience_prompt_get(self.get_prompt_typed(name, arguments)?)
}
/// Completes one prompt or resource-template argument in the selected era.
///
/// Modern sessions send the full [`CompletionParams`] context plus final
/// request metadata and return [`CoreResult::Final`] with
/// [`FinalCoreResult::Completion`]. Exact legacy sessions losslessly map
/// only title-free, context-free inputs and return [`CoreResult::Legacy`].
///
/// # Errors
///
/// Returns an error if a legacy session cannot represent the requested
/// completion input, if the request fails, or if its result violates the
/// method-aware contract of the negotiated era. A contradictory peer
/// result terminates the connection.
pub fn complete(&mut self, params: CompletionParams) -> McpResult<CoreResult> {
self.ensure_initialized()?;
match self.session.selected_era() {
Some(ProtocolEra::Modern2026) => {
self.send_typed_core_request("completion/complete", params)
}
Some(ProtocolEra::Legacy2024) => {
self.send_typed_core_request("completion/complete", params.into_legacy()?)
}
None => Err(McpError::internal_error(
"Client has no negotiated protocol era for completion",
)),
}
}
/// Completes one prompt or resource-template argument and admits
/// request-scoped `notifications/progress` for the supplied marker.
pub fn complete_with_progress_marker(
&mut self,
params: CompletionParams,
progress_marker: ProgressMarker,
) -> McpResult<CoreResult> {
self.ensure_initialized()?;
let token = serde_json::to_value(progress_marker)
.map_err(|_| McpError::internal_error("stdio progress token could not be encoded"))?;
let mut parameters = match self.session.selected_era() {
Some(ProtocolEra::Modern2026) => serde_json::to_value(params).map_err(|_| {
McpError::internal_error("stdio modern completion parameters could not serialize")
})?,
Some(ProtocolEra::Legacy2024) => {
serde_json::to_value(params.into_legacy()?).map_err(|_| {
McpError::internal_error(
"stdio legacy completion parameters could not serialize",
)
})?
}
None => {
return Err(McpError::internal_error(
"Client has no negotiated protocol era for completion",
));
}
};
let object = parameters.as_object_mut().ok_or_else(|| {
McpError::internal_error("stdio completion parameters must remain an object")
})?;
object.insert(
"_meta".to_owned(),
serde_json::json!({ "progressToken": token }),
);
self.send_typed_core_request("completion/complete", parameters)
}
/// Completes one prompt or resource-template argument under a
/// request-local cancellation domain.
///
/// The callback observes the allocated upstream JSON-RPC ID only after
/// its request has committed. A request-local cancellation observed
/// before that point makes no transport contact; one observed afterwards
/// sends the selected-era cancellation control before this method returns.
pub fn complete_with_cancellation<F>(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
params: CompletionParams,
on_committed: F,
) -> McpResult<CoreResult>
where
F: FnOnce(&RequestId),
{
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
return Err(McpError::request_cancelled());
}
self.ensure_initialized()?;
let parameters = match self.session.selected_era() {
Some(ProtocolEra::Modern2026) => serde_json::to_value(params).map_err(|error| {
McpError::invalid_params(format!(
"Client modern completion parameters could not serialize: {error}"
))
})?,
Some(ProtocolEra::Legacy2024) => {
serde_json::to_value(params.into_legacy()?).map_err(|error| {
McpError::invalid_params(format!(
"Client legacy completion parameters could not serialize: {error}"
))
})?
}
None => {
return Err(McpError::internal_error(
"Client has no negotiated protocol era for completion",
));
}
};
self.request_core_with_cancellation(
cx,
cancellation,
"completion/complete",
parameters,
on_committed,
)
}
/// Collects one final typed subscription listener until its terminal result.
///
/// The acknowledgement must bind its subscription ID to this listen
/// request and may accept only a subset of `notifications`. The collector
/// retains only acknowledged catalog/resource events, then returns the
/// exact terminal [`CompleteResult`]. Exact 2024-11-05 has no equivalent
/// listener contract and is rejected before a request ID is allocated or
/// bytes are written.
///
/// This occupies ingress until the stream ends. To keep issuing requests
/// on the same `Client` while catalog events arrive, use
/// [`Self::open_subscriptions_listener`] and
/// [`Self::next_subscription_event`].
pub fn listen_subscriptions_typed(
&mut self,
notifications: SubscriptionFilter,
) -> McpResult<SubscriptionListenCollector> {
self.ensure_initialized()?;
if self.session.selected_era() != Some(ProtocolEra::Modern2026) {
return Err(McpError::invalid_params(
"subscriptions/listen is available only for MCP 2026-07-28",
));
}
let timeout_policy = self.timeout_policy;
timeout_policy.validate()?;
let requested = notifications;
#[cfg(feature = "tasks")]
let tasks_requested = task_subscription_ids(&requested)
.map_err(|_| McpError::invalid_params("invalid Tasks subscription filter"))?
.is_some();
#[cfg(feature = "tasks")]
if tasks_requested {
self.admit_final_tasks_direction(
TASK_STATUS_NOTIFICATION,
ExtensionDirection::ServerToClient,
)?;
}
let params_value = serde_json::to_value(serde_json::json!({
"notifications": requested.clone(),
}))
.map_err(|error| {
McpError::internal_error(format!(
"Failed to serialize subscriptions/listen parameters: {error}"
))
})?;
#[cfg(feature = "tasks")]
let params_value = if tasks_requested {
self.with_final_tasks_client_capability(params_value)?
} else {
self.prepare_request_parameters(params_value)?
};
#[cfg(not(feature = "tasks"))]
let params_value = self.prepare_request_parameters(params_value)?;
let core_request = self
.prepared_core_request("subscriptions/listen", ¶ms_value)?
.ok_or_else(|| {
McpError::invalid_params(
"subscriptions/listen is not a supported core request in the negotiated era",
)
})?;
let params_value = core_request
.encode_params()
.map_err(|_| {
McpError::invalid_params(
"subscriptions/listen could not be encoded in the negotiated protocol era",
)
})?
.ok_or_else(|| {
McpError::invalid_params(
"subscriptions/listen requires a parameter object in the negotiated protocol era",
)
})?;
let id = self.next_request_id()?;
let id_i64 = i64::try_from(id).expect("request ID allocator enforces the i64 bound");
let request_id = RequestId::Number(id_i64);
let request = JsonRpcRequest::new("subscriptions/listen", Some(params_value), id_i64);
let waiter = self.responses.register(request_id.clone())?;
if let Err(error) = self.send_to_server(&JsonRpcMessage::Request(request)) {
return Err(self.record_send_failure(Some(&request_id), error));
}
let committed_at = Instant::now();
let deadlines = match RequestDeadlines::start_at(timeout_policy, committed_at) {
Ok(deadlines) => deadlines,
Err(error) => return Err(self.finish_committed_request_locally(&request_id, error)),
};
self.recv_subscription_listener(waiter, &core_request, &requested, deadlines)
}
/// Starts a real, incrementally driven final catalog subscription on this
/// stdio connection.
///
/// Unlike [`Self::listen_subscriptions_typed`], this does not collect the
/// stream to terminal completion. Call [`Self::next_subscription_event`]
/// to let this `Client` keep sole ownership of ingress while exposing each
/// acknowledged catalog or resource-update event in arrival order. The
/// same `Client` can still issue ordinary requests such as `tools/call`.
pub fn open_subscriptions_listener(
&mut self,
notifications: SubscriptionFilter,
) -> McpResult<()> {
self.ensure_initialized()?;
if self.live_catalog_subscription.is_some() {
return Err(McpError::invalid_request(
"A final catalog stdio subscription is already active on this client",
));
}
if self.session.selected_era() != Some(ProtocolEra::Modern2026) {
return Err(McpError::invalid_params(
"subscriptions/listen is available only for MCP 2026-07-28",
));
}
#[cfg(feature = "tasks")]
{
let tasks_requested = task_subscription_ids(¬ifications)
.map_err(|_| McpError::invalid_params("invalid Tasks subscription filter"))?
.is_some();
if tasks_requested {
return Err(McpError::invalid_params(
"A live catalog subscription cannot include taskIds; use open_final_task_subscription_listener",
));
}
}
if !catalog_subscription_requested(¬ifications) {
return Err(McpError::invalid_params(
"A live catalog subscription requires tools, resources, or prompts list_changed or resourceSubscriptions",
));
}
let params_value = serde_json::to_value(serde_json::json!({
"notifications": notifications.clone(),
}))
.map_err(|error| {
McpError::internal_error(format!(
"Failed to serialize subscriptions/listen parameters: {error}"
))
})?;
let params_value = self.prepare_request_parameters(params_value)?;
let core_request = self
.prepared_core_request("subscriptions/listen", ¶ms_value)?
.ok_or_else(|| {
McpError::invalid_params(
"subscriptions/listen is not a supported core request in the negotiated era",
)
})?;
let params_value = core_request
.encode_params()
.map_err(|_| {
McpError::invalid_params(
"subscriptions/listen could not be encoded in the negotiated protocol era",
)
})?
.ok_or_else(|| {
McpError::invalid_params(
"subscriptions/listen requires a parameter object in the negotiated protocol era",
)
})?;
let executor = self.multiplexed_stdio_executor()?;
executor.service(&self.cx)?;
let execution = executor.execute(&self.cx, "subscriptions/listen", Some(params_value))?;
self.live_catalog_subscription = Some(LiveStdioCatalogSubscription {
executor,
execution,
core_request,
requested_filter: notifications,
accepted_filter: None,
acknowledgement_delivered: false,
pending_notifications: VecDeque::new(),
cancellation_failure: None,
});
Ok(())
}
/// Commits upstream cancellation before retiring the live catalog listener.
///
/// A missing listener is a no-op so Drop / next-async cancel paths can
/// retire a route that never installed one. The listener remains installed
/// when the cancellation control cannot be committed.
pub fn cancel_live_catalog_subscription(&mut self, cx: &Cx) -> McpResult<()> {
if self.live_catalog_subscription.is_none() {
return Ok(());
}
let cancellation = {
let subscription = self.live_catalog_subscription.as_mut().ok_or_else(|| {
McpError::invalid_request("No live final catalog stdio subscription is active")
})?;
if let Some(error) = &subscription.cancellation_failure {
return Err(error.clone());
}
subscription
.executor
.cancel(cx, &mut subscription.execution)
};
match cancellation {
Ok(()) => {
self.live_catalog_subscription = None;
Ok(())
}
Err(error) => {
if let Some(subscription) = self.live_catalog_subscription.as_mut() {
subscription.cancellation_failure = Some(error.clone());
}
Err(error)
}
}
}
fn harvest_live_catalog_subscription_notifications(&mut self) -> McpResult<()> {
if self.live_catalog_subscription.is_none() {
return Ok(());
}
let queued: Vec<ServerNotification> = self.final_server_notifications.drain(..).collect();
let mut remainder = VecDeque::new();
let mut harvest_error = None;
for notification in queued {
if harvest_error.is_some() {
remainder.push_back(notification);
continue;
}
let Some(subscription) = self.live_catalog_subscription.as_mut() else {
remainder.push_back(notification);
continue;
};
match notification {
ServerNotification::SubscriptionsAcknowledged(acknowledgement) => {
if subscription_acknowledgement_is_foreign(
subscription.execution.request_id(),
&acknowledgement,
) {
remainder.push_back(ServerNotification::SubscriptionsAcknowledged(
acknowledgement,
));
continue;
}
if subscription.accepted_filter.is_some() {
harvest_error = Some(subscription_listener_protocol_error(
"Subscription listener received a duplicate acknowledgement",
));
continue;
}
if let Err(error) = validate_subscription_acknowledgement(
subscription.execution.request_id(),
&subscription.requested_filter,
&acknowledgement,
) {
harvest_error = Some(error);
continue;
}
subscription.accepted_filter = Some(acknowledgement.notifications);
}
notification @ (ServerNotification::ResourcesListChanged(_)
| ServerNotification::ToolsListChanged(_)
| ServerNotification::PromptsListChanged(_)
| ServerNotification::ResourceUpdated(_)) => {
let Some(accepted_filter) = subscription.accepted_filter.as_ref() else {
harvest_error = Some(subscription_listener_protocol_error(
"Subscription listener received an event before acknowledgement",
));
continue;
};
if let Err(error) =
validate_subscription_notification_filter(¬ification, accepted_filter)
{
harvest_error = Some(error);
continue;
}
if subscription.pending_notifications.len()
>= MAX_QUEUED_FINAL_SERVER_NOTIFICATIONS
{
harvest_error = Some(McpError::invalid_request(
FINAL_SERVER_NOTIFICATION_QUEUE_OVERFLOW_ERROR,
));
continue;
}
subscription.pending_notifications.push_back(notification);
}
other => remainder.push_back(other),
}
}
self.final_server_notifications = remainder;
if let Some(error) = harvest_error {
return Err(self.terminate_connection(error));
}
Ok(())
}
/// Drives the sole stdio ingress reader until one live catalog listener
/// event is available.
///
/// Catalog and resource-update events admitted while another sequential
/// request (for example `tools/call`) is in flight are harvested from the
/// connection-level queue, so the same `Client` can mutate the catalog and
/// observe `list_changed` without collecting this stream to terminal.
pub fn next_subscription_event(
&mut self,
cx: &Cx,
cancellation: &fastmcp_core::McpRequestCancellation,
) -> McpResult<StdioSubscriptionEvent> {
if let Some(error) = self
.live_catalog_subscription
.as_ref()
.and_then(|subscription| subscription.cancellation_failure.clone())
{
return Err(error);
}
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
self.cancel_live_catalog_subscription(cx)?;
return Err(McpError::request_cancelled());
}
loop {
self.harvest_live_catalog_subscription_notifications()?;
if let Some(event) = self.take_ready_catalog_subscription_event()? {
return Ok(event);
}
self.drive_multiplexed_stdio(cx)?;
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
self.cancel_live_catalog_subscription(cx)?;
return Err(McpError::request_cancelled());
}
}
}
fn take_ready_catalog_subscription_event(
&mut self,
) -> McpResult<Option<StdioSubscriptionEvent>> {
// Function-local drain state; the wide terminal variant never leaves
// this call frame, so boxing only adds indirection.
#[allow(clippy::large_enum_variant)]
enum ReadyCatalog {
Event(StdioSubscriptionEvent),
Terminal {
response: JsonRpcResponse,
raw_result: Option<String>,
request_id: RequestId,
core_request: CoreRequest,
accepted_filter: Option<SubscriptionFilter>,
},
Waiting,
Failed(McpError),
}
let ready = {
let subscription = self.live_catalog_subscription.as_mut().ok_or_else(|| {
McpError::invalid_request("No live final catalog stdio subscription is active")
})?;
if !subscription.acknowledgement_delivered
&& let Some(acknowledged) = subscription.accepted_filter.clone()
{
subscription.acknowledgement_delivered = true;
ReadyCatalog::Event(StdioSubscriptionEvent::Acknowledged(acknowledged))
} else if let Some(notification) = subscription.pending_notifications.pop_front() {
ReadyCatalog::Event(StdioSubscriptionEvent::Notification(notification))
} else {
match subscription
.executor
.try_take_response_with_raw_result(&mut subscription.execution)
{
Ok(Some((response, raw_result))) => ReadyCatalog::Terminal {
response,
raw_result,
request_id: subscription.execution.request_id().clone(),
core_request: subscription.core_request.clone(),
accepted_filter: subscription.accepted_filter.clone(),
},
Ok(None) => ReadyCatalog::Waiting,
Err(error) => ReadyCatalog::Failed(error),
}
}
};
match ready {
ReadyCatalog::Event(event) => Ok(Some(event)),
ReadyCatalog::Waiting => Ok(None),
ReadyCatalog::Failed(error) => {
self.live_catalog_subscription = None;
Err(error)
}
ReadyCatalog::Terminal {
response,
raw_result,
request_id,
core_request,
accepted_filter,
} => {
let Some(raw_result) = raw_result.as_deref() else {
self.live_catalog_subscription = None;
return Err(self.terminate_connection(McpError::invalid_request(
"Final subscriptions/listen response lost its admitted result source",
)));
};
let result = match core_request.decode_response_result(&response, raw_result) {
Ok(result) => result,
Err(error) => {
self.live_catalog_subscription = None;
return Err(self.terminate_connection(McpError::invalid_request(format!(
"Invalid final subscriptions/listen termination: {error}"
))));
}
};
let CoreResult::Final(FinalCoreResult::SubscriptionsListen {
subscription_id, ..
}) = result
else {
self.live_catalog_subscription = None;
return Err(
self.terminate_connection(subscription_listener_protocol_error(
"Subscription listener received a non-listen terminal result",
)),
);
};
if !subscription_id.correlates_with(&request_id) {
self.live_catalog_subscription = None;
return Err(
self.terminate_connection(subscription_listener_protocol_error(
"Subscription listener terminal ID does not match its request",
)),
);
}
if accepted_filter.is_none() {
self.live_catalog_subscription = None;
return Err(
self.terminate_connection(subscription_listener_protocol_error(
"Subscription listener terminated before acknowledgement",
)),
);
}
self.live_catalog_subscription = None;
Ok(Some(StdioSubscriptionEvent::Terminal))
}
}
}
/// Takes a ready catalog listener event, or drives one bounded stdio
/// receive turn. `None` means the bound elapsed without an event so a
/// proxy route can drop its mutex.
pub fn try_next_subscription_event(
&mut self,
cx: &Cx,
cancellation: &fastmcp_core::McpRequestCancellation,
) -> McpResult<Option<StdioSubscriptionEvent>> {
const STDIO_CATALOG_LISTEN_RECEIVE_BOUND: Duration = Duration::from_millis(20);
if let Some(error) = self
.live_catalog_subscription
.as_ref()
.and_then(|subscription| subscription.cancellation_failure.clone())
{
return Err(error);
}
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
self.cancel_live_catalog_subscription(cx)?;
return Err(McpError::request_cancelled());
}
self.harvest_live_catalog_subscription_notifications()?;
if let Some(event) = self.take_ready_catalog_subscription_event()? {
return Ok(Some(event));
}
self.drive_multiplexed_stdio_until(
cx,
Some(Instant::now() + STDIO_CATALOG_LISTEN_RECEIVE_BOUND),
)?;
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
self.cancel_live_catalog_subscription(cx)?;
return Err(McpError::request_cancelled());
}
self.harvest_live_catalog_subscription_notifications()?;
self.take_ready_catalog_subscription_event()
}
/// Moves already-queued connection notifications into the live catalog
/// listener and returns resource URIs whose updates are ready.
pub fn take_ready_catalog_resource_updated_uris(&mut self) -> McpResult<Vec<String>> {
if self.live_catalog_subscription.is_none() {
return Ok(Vec::new());
}
self.harvest_live_catalog_subscription_notifications()?;
let Some(subscription) = self.live_catalog_subscription.as_mut() else {
return Ok(Vec::new());
};
let mut uris = Vec::new();
let mut kept = VecDeque::new();
while let Some(notification) = subscription.pending_notifications.pop_front() {
match notification {
ServerNotification::ResourceUpdated(params) => {
uris.push(params.uri.as_str().to_owned());
}
other => kept.push_back(other),
}
}
subscription.pending_notifications = kept;
Ok(uris)
}
/// Starts a real, incrementally driven final Tasks subscription on this
/// stdio connection.
///
/// Unlike [`Self::listen_subscriptions_typed`], this does not collect the
/// stream to terminal completion. Call
/// [`Self::next_final_task_subscription_event`] to let this `Client` keep
/// sole ownership of ingress while exposing each acknowledged Tasks update
/// in arrival order.
#[cfg(feature = "tasks")]
pub fn open_final_task_subscription_listener(
&mut self,
notifications: SubscriptionFilter,
) -> McpResult<()> {
self.ensure_initialized()?;
if self.live_task_subscription.is_some() {
return Err(McpError::invalid_request(
"A final Tasks stdio subscription is already active on this client",
));
}
if self.session.selected_era() != Some(ProtocolEra::Modern2026) {
return Err(McpError::invalid_params(
"subscriptions/listen is available only for MCP 2026-07-28",
));
}
let tasks_requested = task_subscription_ids(¬ifications)
.map_err(|_| McpError::invalid_params("invalid Tasks subscription filter"))?
.is_some();
if !tasks_requested {
return Err(McpError::invalid_params(
"A live final Tasks subscription requires taskIds",
));
}
self.admit_final_tasks_direction(
TASK_STATUS_NOTIFICATION,
ExtensionDirection::ServerToClient,
)?;
let params_value = serde_json::to_value(serde_json::json!({
"notifications": notifications,
}))
.map_err(|error| {
McpError::internal_error(format!(
"Failed to serialize subscriptions/listen parameters: {error}"
))
})?;
let params_value = self.with_final_tasks_client_capability(params_value)?;
let core_request = self
.prepared_core_request("subscriptions/listen", ¶ms_value)?
.ok_or_else(|| {
McpError::invalid_params(
"subscriptions/listen is not a supported core request in the negotiated era",
)
})?;
let params_value = core_request
.encode_params()
.map_err(|_| {
McpError::invalid_params(
"subscriptions/listen could not be encoded in the negotiated protocol era",
)
})?
.ok_or_else(|| {
McpError::invalid_params(
"subscriptions/listen requires a parameter object in the negotiated protocol era",
)
})?;
let executor = self.multiplexed_stdio_executor()?;
executor.service(&self.cx)?;
let execution = executor.execute_final_tasks_subscription(&self.cx, params_value)?;
self.live_task_subscription = Some(LiveStdioTaskSubscription {
executor,
execution,
acknowledgement_delivered: false,
pending_notifications: VecDeque::new(),
cancellation_failure: None,
});
Ok(())
}
/// Commits upstream cancellation before retiring the live listener owner.
///
/// The listener remains installed when the cancellation control cannot be
/// committed. This preserves its request ownership and retains the
/// original transport failure instead of silently converting it into a
/// successful local cancellation.
#[cfg(feature = "tasks")]
pub fn cancel_live_final_task_subscription(&mut self, cx: &Cx) -> McpResult<()> {
if self.live_task_subscription.is_none() {
return Ok(());
}
let cancellation = {
let subscription = self.live_task_subscription.as_mut().ok_or_else(|| {
McpError::invalid_request("No live final Tasks stdio subscription is active")
})?;
if let Some(error) = &subscription.cancellation_failure {
return Err(error.clone());
}
subscription
.executor
.cancel_tasks_subscription(cx, &mut subscription.execution)
};
match cancellation {
Ok(()) => {
self.live_task_subscription = None;
Ok(())
}
Err(error) => {
if let Some(subscription) = self.live_task_subscription.as_mut() {
subscription.cancellation_failure = Some(error.clone());
}
Err(error)
}
}
}
/// Drives the sole stdio ingress reader until one live Tasks listener
/// event is available.
#[cfg(feature = "tasks")]
pub fn next_final_task_subscription_event(
&mut self,
cx: &Cx,
cancellation: &fastmcp_core::McpRequestCancellation,
) -> McpResult<StdioTaskSubscriptionEvent> {
loop {
if let Some(event) = self.try_next_final_task_subscription_event(cx, cancellation)? {
return Ok(event);
}
}
}
/// Takes one already-queued Tasks listener event, or drives one bounded
/// stdio receive turn when the queue is empty.
///
/// A gateway listen must release the route mutex between receive turns so
/// a peer `tasks/cancel` can write on the same stdio session. `None` means
/// the bounded receive completed without a Tasks event.
#[cfg(feature = "tasks")]
pub fn try_next_final_task_subscription_event(
&mut self,
cx: &Cx,
cancellation: &fastmcp_core::McpRequestCancellation,
) -> McpResult<Option<StdioTaskSubscriptionEvent>> {
const STDIO_TASK_LISTEN_RECEIVE_BOUND: Duration = Duration::from_millis(20);
if let Some(error) = self
.live_task_subscription
.as_ref()
.and_then(|subscription| subscription.cancellation_failure.clone())
{
return Err(error);
}
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
self.cancel_live_final_task_subscription(cx)?;
return Err(McpError::request_cancelled());
}
if let Some(event) = self.take_ready_final_task_subscription_event()? {
return Ok(Some(event));
}
self.drive_multiplexed_stdio_until(
cx,
Some(Instant::now() + STDIO_TASK_LISTEN_RECEIVE_BOUND),
)?;
if cancellation.is_cancel_requested() || cx.checkpoint().is_err() {
self.cancel_live_final_task_subscription(cx)?;
return Err(McpError::request_cancelled());
}
self.take_ready_final_task_subscription_event()
}
#[cfg(feature = "tasks")]
fn take_ready_final_task_subscription_event(
&mut self,
) -> McpResult<Option<StdioTaskSubscriptionEvent>> {
let event = {
let subscription = self.live_task_subscription.as_mut().ok_or_else(|| {
McpError::invalid_request("No live final Tasks stdio subscription is active")
})?;
if !subscription.acknowledgement_delivered
&& let Some(acknowledged) = subscription
.executor
.tasks_subscription_acknowledgement(&subscription.execution)?
{
subscription.acknowledgement_delivered = true;
Some(StdioTaskSubscriptionEvent::Acknowledged(acknowledged))
} else {
if subscription.pending_notifications.is_empty() {
subscription.pending_notifications.extend(
subscription
.executor
.take_tasks_subscription_notifications(&subscription.execution)?,
);
}
if let Some(notification) = subscription.pending_notifications.pop_front() {
Some(StdioTaskSubscriptionEvent::Notification(notification))
} else if subscription
.executor
.try_take_tasks_subscription_terminal(&mut subscription.execution)?
.is_some()
{
Some(StdioTaskSubscriptionEvent::Terminal)
} else {
None
}
}
};
if matches!(event, Some(StdioTaskSubscriptionEvent::Terminal)) {
self.live_task_subscription = None;
}
Ok(event)
}
// ═══════════════════════════════════════════════════════════════════════
// Final Tasks extension
// ═══════════════════════════════════════════════════════════════════════
/// Reads one task through the negotiated official Tasks extension.
///
/// Exact MCP 2024-11-05 excludes extensions, so this rejects before a
/// request ID is allocated or any task bytes are written. Modern callers
/// must have a bilateral `io.modelcontextprotocol/tasks` declaration with
/// exactly empty settings in the retained discovery response.
#[cfg(feature = "tasks")]
pub fn get_task_final(&mut self, task_id: FinalTaskId) -> McpResult<FinalGetTaskResult> {
self.admit_final_tasks_method(TASK_GET)?;
let params = FinalGetTaskParams {
request: self.final_task_request_meta()?,
task_id: task_id.clone(),
};
let result: FinalGetTaskResult = self.send_final_task_request(TASK_GET, params)?;
if result.task.base().task_id != task_id {
return Err(self.terminate_connection(McpError::invalid_request(
"tasks/get response taskId does not match the requested final task",
)));
}
Ok(result)
}
/// Reads one task under a request-local cancellation domain.
#[cfg(feature = "tasks")]
pub fn get_task_final_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
task_id: FinalTaskId,
) -> McpResult<FinalGetTaskResult> {
self.admit_final_tasks_method(TASK_GET)?;
let params = FinalGetTaskParams {
request: self.final_task_request_meta()?,
task_id: task_id.clone(),
};
let result: FinalGetTaskResult =
self.send_final_task_request_with_cancellation(cx, cancellation, TASK_GET, params)?;
if result.task.base().task_id != task_id {
return Err(self.terminate_connection(McpError::invalid_request(
"tasks/get response taskId does not match the requested final task",
)));
}
Ok(result)
}
/// Supplies responses for the exact input requests retained by a final
/// `input_required` task.
///
/// Passing the returned [`FinalTask`] retains the task identifier and
/// request ledger as one correlated unit. The client rejects a non-input
/// task or a response key/kind contradiction before sending `tasks/update`.
#[cfg(feature = "tasks")]
pub fn update_task_final(
&mut self,
task: &FinalTask,
input_responses: FinalTaskInputResponses,
) -> McpResult<FinalUpdateTaskResult> {
self.admit_final_tasks_method(TASK_UPDATE)?;
let FinalTask::InputRequired {
base,
input_requests,
} = task
else {
return Err(McpError::invalid_params(
"tasks/update requires an input_required final task",
));
};
let ledger = TaskInputLedger::from_requests(input_requests).map_err(|_| {
McpError::invalid_params("Final task input requests are not an admitted ledger")
})?;
ledger.validate_responses(&input_responses).map_err(|_| {
McpError::invalid_params(
"tasks/update inputResponses do not match the retained task input requests",
)
})?;
let params = FinalUpdateTaskParams {
request: self.final_task_request_meta()?,
task_id: base.task_id.clone(),
input_responses,
};
self.send_final_task_request(TASK_UPDATE, params)
}
/// Updates one input-required task under a request-local cancellation
/// domain.
#[cfg(feature = "tasks")]
pub fn update_task_final_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
task: &FinalTask,
input_responses: FinalTaskInputResponses,
) -> McpResult<FinalUpdateTaskResult> {
self.admit_final_tasks_method(TASK_UPDATE)?;
let FinalTask::InputRequired {
base,
input_requests,
} = task
else {
return Err(McpError::invalid_params(
"tasks/update requires an input_required final task",
));
};
let ledger = TaskInputLedger::from_requests(input_requests).map_err(|_| {
McpError::invalid_params("Final task input requests are not an admitted ledger")
})?;
ledger.validate_responses(&input_responses).map_err(|_| {
McpError::invalid_params(
"tasks/update inputResponses do not match the retained task input requests",
)
})?;
self.send_final_task_request_with_cancellation(
cx,
cancellation,
TASK_UPDATE,
FinalUpdateTaskParams {
request: self.final_task_request_meta()?,
task_id: base.task_id.clone(),
input_responses,
},
)
}
/// Requests cancellation through the negotiated official Tasks extension.
///
/// The exact final acknowledgement is intentionally empty; unlike the
/// stale custom task API, it must not invent a projected task snapshot.
#[cfg(feature = "tasks")]
pub fn cancel_task_final(&mut self, task_id: FinalTaskId) -> McpResult<FinalCancelTaskResult> {
self.admit_final_tasks_method(TASK_CANCEL)?;
let params = FinalCancelTaskParams {
request: self.final_task_request_meta()?,
task_id,
};
self.send_final_task_request(TASK_CANCEL, params)
}
/// Cancels one task under a request-local cancellation domain.
#[cfg(feature = "tasks")]
pub fn cancel_task_final_with_cancellation(
&mut self,
cx: &Cx,
cancellation: &McpRequestCancellation,
task_id: FinalTaskId,
) -> McpResult<FinalCancelTaskResult> {
self.admit_final_tasks_method(TASK_CANCEL)?;
self.send_final_task_request_with_cancellation(
cx,
cancellation,
TASK_CANCEL,
FinalCancelTaskParams {
request: self.final_task_request_meta()?,
task_id,
},
)
}
/// Closes the client connection and verifies bounded subprocess cleanup.
///
/// Drop remains a best-effort safety net. Callers that need to prove that
/// an owned subprocess (or configured Unix process group) was stopped must
/// use this explicit method and handle its result. A successful close is
/// idempotent. Retryable process cleanup failures retain the child handle
/// and phase so callers may invoke `close` again without re-signalling a
/// process group after its leader has been reaped.
///
/// Subprocess verification assumes this client exclusively reaps the
/// retained direct children. Process-wide `waitpid(-1)` consumers,
/// `SIGCHLD=SIG_IGN`, and `SA_NOCLDWAIT` can consume that evidence before
/// FastMCP observes it; in that case cleanup fails closed instead of
/// signalling an identity that is no longer proven. Unix process-group
/// ownership also cannot contain descendants that deliberately change
/// process group/session, or guarantee owner-death cleanup while a
/// host-side `fork` retains a copy of the private control descriptor
/// (including a concurrent setup-time fork on Unix targets without atomic
/// close-on-exec socket-pair creation).
///
/// # Errors
///
/// Returns an error when the transport cannot be closed, process state
/// cannot be established, signalling fails, or the subprocess cannot be
/// reaped within the cleanup deadline.
pub fn close(&mut self) -> McpResult<()> {
// A dropped request-owned handle has no later ingress turn to flush
// its cancellation. Service the shared executor before closing child
// stdin so the selected-era control receives one bounded attempt.
let deferred_retirement_result = self
.multiplexed_executor
.as_ref()
.map_or(Ok(()), |executor| executor.service(&self.cx));
self.initialized.store(false, Ordering::SeqCst);
self.responses
.fail_all(McpError::internal_error("Client connection closed"));
if let Some(executor) = &self.multiplexed_executor {
executor.fail_connection(McpError::internal_error("Client connection closed"));
}
self.cancel_reverse_callback_pool();
self.join_reverse_callback_pool()?;
// Transport teardown is one-shot. Preserve any failure because a
// consumed writer cannot make a later close prove that the earlier
// flush/close succeeded.
let transport_result = self.close_transport().map_err(transport_error_to_mcp);
if let Err(error) = transport_result {
self.retain_cleanup_error(error);
}
// Process teardown is phaseful and retryable. Only an error from a
// terminal phase becomes sticky; a later successful quiescence proof
// clears the prior attempt's transient failure.
let process_result = self.stop_retained_child();
let retryable_process_result = match process_result {
Ok(()) => {
self.pending_process_cleanup_error = None;
Ok(())
}
Err(error) if self.child_cleanup_phase == ClientChildCleanupPhase::Complete => {
self.pending_process_cleanup_error = None;
self.retain_cleanup_error(error);
Ok(())
}
Err(error) => {
self.pending_process_cleanup_error = Some(error.clone());
Err(error)
}
};
let sticky_result = self.cleanup_error.clone().map_or(Ok(()), Err);
let result = combine_cleanup_results(
deferred_retirement_result,
combine_cleanup_results(sticky_result, retryable_process_result),
);
if result.is_ok() {
self.pending_process_cleanup_error = None;
}
result
}
}
impl Drop for Client {
fn drop(&mut self) {
// Drop cannot report cleanup failure or create an orphan cleanup task;
// callers requiring proof must call close() and handle its result.
// Cloned request-owned handles can outlive this Client value. Publish
// the same connection terminal outcome before closing their shared
// transport so a surviving handle never remains pending without an
// ingress driver.
self.responses
.fail_all(McpError::internal_error("Client connection closed"));
if let Some(executor) = &self.multiplexed_executor {
executor.fail_connection(McpError::internal_error("Client connection closed"));
}
self.cancel_reverse_callback_pool();
self.abort_reverse_callback_pool_for_drop();
let _ = self.close_transport();
if let Err(error) = self.stop_retained_child() {
log::error!("Client drop could not verify subprocess cleanup: {error}");
}
}
}
/// Converts a TransportError to McpError.
pub(crate) fn transport_error_to_mcp(e: TransportError) -> McpError {
match e {
TransportError::Cancelled => McpError::request_cancelled(),
TransportError::Closed => McpError::internal_error("Transport closed"),
TransportError::Timeout | TransportError::ReceiveDeadlineExceeded => {
McpError::internal_error("Request timed out")
}
TransportError::ControlFrameTooLarge { .. } => {
McpError::internal_error(CONTROL_FRAME_CAPACITY_ERROR)
}
TransportError::Io(io_err) => McpError::internal_error(format!("I/O error: {io_err}")),
// Typed codec failures can contain serde diagnostics that echo an
// attacker-controlled enum value or control characters. The peer's
// frame is never safe diagnostic text, so expose a fixed error here.
TransportError::Codec(_) => McpError::internal_error(TRANSPORT_CODEC_ERROR),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "legacy-2024-11-05")]
use crate::http_executor::ModernHttpConnectOutcome;
fn acknowledgement_with_subscription_id(
id: RequestId,
) -> FinalSubscriptionsAcknowledgedNotificationParams {
let meta = OpenMetadata::try_from_notification_entries([(
FINAL_SUBSCRIPTION_ID_META_KEY.to_owned(),
serde_json::to_value(id).expect("request id serializes"),
)])
.expect("subscription acknowledgement metadata is valid");
FinalSubscriptionsAcknowledgedNotificationParams {
notifications: SubscriptionFilter::default(),
meta: Some(meta),
additional: BTreeMap::new(),
}
}
#[test]
fn foreign_subscription_acknowledgement_is_left_on_the_shared_queue() {
let catalog_id = RequestId::Number(1);
let tasks_id = RequestId::Number(2);
let tasks_ack = acknowledgement_with_subscription_id(tasks_id.clone());
assert!(
subscription_acknowledgement_is_foreign(&catalog_id, &tasks_ack),
"a Tasks acknowledgement must not be consumed by the catalog harvester"
);
assert!(
!subscription_acknowledgement_is_foreign(&tasks_id, &tasks_ack),
"the owning Tasks harvester must still admit its own acknowledgement"
);
let missing_id = FinalSubscriptionsAcknowledgedNotificationParams {
notifications: SubscriptionFilter::default(),
meta: None,
additional: BTreeMap::new(),
};
assert!(
!subscription_acknowledgement_is_foreign(&catalog_id, &missing_id),
"a missing subscription ID remains a protocol error for the active harvester"
);
}
#[cfg(feature = "websocket-experimental")]
use asupersync::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf};
#[cfg(feature = "websocket-experimental")]
use asupersync::test_utils::run_test;
use fastmcp_protocol::ExtensionDirection;
#[cfg(feature = "websocket-experimental")]
use fastmcp_transport::websocket::AsyncWsServerTransport;
use std::collections::{BTreeMap, HashMap};
#[cfg(feature = "websocket-experimental")]
use std::io;
#[test]
fn inbound_capability_overlay_preserves_official_tasks_extension() {
let mut metadata = serde_json::Map::new();
metadata.insert(
FINAL_CLIENT_CAPABILITIES_META_KEY.to_owned(),
serde_json::json!({
"extensions": { fastmcp_protocol::TASKS_EXTENSION: {} }
}),
);
overlay_inbound_core_client_capabilities_on_metadata(
&mut metadata,
&ClientCapabilities::default(),
)
.expect("an empty inbound overlay must keep already-stamped Tasks");
assert_eq!(
metadata[FINAL_CLIENT_CAPABILITIES_META_KEY]["extensions"]
[fastmcp_protocol::TASKS_EXTENSION],
serde_json::json!({})
);
assert!(
metadata[FINAL_CLIENT_CAPABILITIES_META_KEY]
.get("sampling")
.is_none(),
"empty inbound capabilities must not invent sampling: {metadata:?}"
);
}
#[test]
fn inbound_capability_overlay_applies_sampling_without_dropping_tasks() {
let mut metadata = serde_json::Map::new();
metadata.insert(
FINAL_CLIENT_CAPABILITIES_META_KEY.to_owned(),
serde_json::json!({
"extensions": { fastmcp_protocol::TASKS_EXTENSION: {} }
}),
);
overlay_inbound_core_client_capabilities_on_metadata(
&mut metadata,
&ClientCapabilities {
sampling: Some(SamplingCapability::default()),
..ClientCapabilities::default()
},
)
.expect("sampling overlay must keep already-stamped Tasks");
assert_eq!(
metadata[FINAL_CLIENT_CAPABILITIES_META_KEY]["extensions"]
[fastmcp_protocol::TASKS_EXTENSION],
serde_json::json!({})
);
assert_eq!(
metadata[FINAL_CLIENT_CAPABILITIES_META_KEY]["sampling"],
serde_json::json!({})
);
}
#[cfg(feature = "websocket-experimental")]
use std::net::SocketAddr;
#[cfg(feature = "websocket-experimental")]
use std::pin::Pin;
#[cfg(feature = "websocket-experimental")]
use std::task::{Context, Poll};
struct WebSocketTestRecv {
frames: VecDeque<Box<[u8]>>,
}
impl TransportRecvHalf for WebSocketTestRecv {
fn recv(&mut self, cx: &Cx) -> Result<JsonRpcMessage, TransportError> {
self.recv_with_source(cx)
.map(ReceivedTransportFrame::into_message)
}
fn close(&mut self) -> Result<(), TransportError> {
self.frames.clear();
Ok(())
}
}
impl ClientTransportRecvHalf for WebSocketTestRecv {
fn recv_with_source(&mut self, _cx: &Cx) -> Result<ReceivedTransportFrame, TransportError> {
let source = self.frames.pop_front().ok_or(TransportError::Closed)?;
ReceivedTransportFrame::admit(source)
}
}
#[derive(Clone)]
struct WebSocketTestSend {
sent: Arc<Mutex<Vec<JsonRpcMessage>>>,
closed: Arc<AtomicBool>,
}
impl TransportSendHalf for WebSocketTestSend {
fn send(&mut self, _cx: &Cx, message: &JsonRpcMessage) -> Result<(), TransportError> {
self.sent
.lock()
.map_err(|_| TransportError::Closed)?
.push(message.clone());
Ok(())
}
fn close(&mut self) -> Result<(), TransportError> {
self.closed.store(true, Ordering::Release);
Ok(())
}
}
fn websocket_test_frame(source: &str) -> Box<[u8]> {
source.as_bytes().to_vec().into_boxed_slice()
}
fn websocket_test_modern_discovery_frame(id: i64) -> Box<[u8]> {
websocket_test_frame(&format!(
r#"{{"jsonrpc":"2.0","id":{id},"result":{{"resultType":"complete","supportedVersions":["2026-07-28"],"capabilities":{{}},"_meta":{{"io.modelcontextprotocol/serverInfo":{{"name":"websocket-test","version":"1.0"}}}},"ttlMs":0,"cacheScope":"private"}}}}"#
))
}
#[cfg(feature = "websocket-experimental")]
fn async_websocket_pair() -> (
asupersync::net::tcp::VirtualTcpStream,
asupersync::net::tcp::VirtualTcpStream,
) {
let client_addr: SocketAddr = "127.0.0.1:45101".parse().expect("client address");
let server_addr: SocketAddr = "127.0.0.1:45102".parse().expect("server address");
asupersync::net::tcp::VirtualTcpStream::pair(client_addr, server_addr)
}
/// Counts terminal RFC 6455 Close-frame elections on the client writer.
/// The split receive and send halves share that writer, so exactly one
/// count proves teardown did not run twice after a handshake failure.
#[cfg(feature = "websocket-experimental")]
struct CloseCountingIo {
inner: asupersync::net::tcp::VirtualTcpStream,
close_frames: Arc<AtomicUsize>,
}
#[cfg(feature = "websocket-experimental")]
impl CloseCountingIo {
fn new(
inner: asupersync::net::tcp::VirtualTcpStream,
close_frames: Arc<AtomicUsize>,
) -> Self {
Self {
inner,
close_frames,
}
}
}
#[cfg(feature = "websocket-experimental")]
impl AsyncRead for CloseCountingIo {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
Pin::new(&mut self.get_mut().inner).poll_read(cx, buf)
}
}
#[cfg(feature = "websocket-experimental")]
impl AsyncWrite for CloseCountingIo {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
bytes: &[u8],
) -> Poll<io::Result<usize>> {
let this = self.get_mut();
if bytes.first().is_some_and(|byte| byte & 0x0F == 0x08) {
this.close_frames.fetch_add(1, Ordering::SeqCst);
}
Pin::new(&mut this.inner).poll_write(cx, bytes)
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.get_mut().inner).poll_flush(cx)
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
}
}
#[cfg(feature = "websocket-experimental")]
fn close_counting_async_websocket_pair() -> (
CloseCountingIo,
asupersync::net::tcp::VirtualTcpStream,
Arc<AtomicUsize>,
) {
let (client, peer) = async_websocket_pair();
let close_frames = Arc::new(AtomicUsize::new(0));
(
CloseCountingIo::new(client, Arc::clone(&close_frames)),
peer,
close_frames,
)
}
/// Allows discovery and the committed request through, then makes the
/// cancellation notification's text-frame write remain pending forever.
/// Close frames remain writable so the production timeout path can settle
/// its transport without a detached sender.
#[cfg(feature = "websocket-experimental")]
struct StalledCancellationWriteIo {
inner: asupersync::net::tcp::VirtualTcpStream,
sent_text_frames: usize,
}
#[cfg(feature = "websocket-experimental")]
impl AsyncRead for StalledCancellationWriteIo {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
Pin::new(&mut self.get_mut().inner).poll_read(cx, buf)
}
}
#[cfg(feature = "websocket-experimental")]
impl AsyncWrite for StalledCancellationWriteIo {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
bytes: &[u8],
) -> Poll<io::Result<usize>> {
let this = self.get_mut();
if bytes.first().is_some_and(|byte| byte & 0x0F == 0x01) {
if this.sent_text_frames >= 2 {
return Poll::Pending;
}
this.sent_text_frames += 1;
}
Pin::new(&mut this.inner).poll_write(cx, bytes)
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.get_mut().inner).poll_flush(cx)
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
}
}
#[cfg(feature = "websocket-experimental")]
fn stalled_cancellation_async_websocket_pair() -> (
StalledCancellationWriteIo,
asupersync::net::tcp::VirtualTcpStream,
) {
let (client, peer) = async_websocket_pair();
(
StalledCancellationWriteIo {
inner: client,
sent_text_frames: 0,
},
peer,
)
}
#[cfg(feature = "websocket-experimental")]
fn async_modern_discovery_result() -> serde_json::Value {
serde_json::json!({
"resultType": "complete",
"supportedVersions": [MODERN_PROTOCOL_VERSION],
"capabilities": {},
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "async-websocket-test",
"version": "1.0"
}
},
"ttlMs": 0,
"cacheScope": "private"
})
}
#[cfg(feature = "websocket-experimental")]
fn async_modern_tools_list_result() -> serde_json::Value {
serde_json::json!({
"resultType": "complete",
"tools": [],
"ttlMs": 0,
"cacheScope": "private"
})
}
#[cfg(feature = "websocket-experimental")]
fn async_websocket_client_info() -> ClientInfo {
ClientInfo {
name: "async-websocket-client".to_owned(),
version: "1.0".to_owned(),
}
}
#[cfg(feature = "websocket-experimental")]
async fn write_server_text_frame(
peer: &mut asupersync::net::tcp::VirtualTcpStream,
source: &str,
) {
let payload = source.as_bytes();
let mut frame = Vec::with_capacity(payload.len() + 10);
frame.push(0x81);
if payload.len() <= 125 {
frame.push(u8::try_from(payload.len()).expect("small WebSocket payload"));
} else {
frame.push(126);
frame.extend_from_slice(
&u16::try_from(payload.len())
.expect("bounded test WebSocket payload")
.to_be_bytes(),
);
}
frame.extend_from_slice(payload);
peer.write_all(&frame)
.await
.expect("write raw server WebSocket text frame");
}
#[cfg(feature = "websocket-experimental")]
fn raw_modern_discovery_source(id: &str) -> String {
format!(
r#"{{"jsonrpc":"2.0","id":{id},"result":{{"resultType":"complete","supportedVersions":["2026-07-28"],"capabilities":{{}},"_meta":{{"io.modelcontextprotocol/serverInfo":{{"name":"raw-async-websocket-test","version":"1.0"}}}},"ttlMs":0,"cacheScope":"private"}}}}"#
)
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_async_public_correlation_admits_equivalent_numeric_ids_and_raw_lexemes() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
for response_id in ["2", "2.0", "2e0"] {
let (client_io, mut peer_io) = async_websocket_pair();
write_server_text_frame(&mut peer_io, &raw_modern_discovery_source("1.0")).await;
write_server_text_frame(
&mut peer_io,
&format!(
r#"{{"jsonrpc":"2.0","id":{response_id},"result":{{"firstExtra":"a","number":1.20e+4,"lastExtra":"z"}}}}"#
),
)
.await;
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("equivalent numeric discovery ID is admitted");
let parameters = client
.with_modern_request_metadata(serde_json::json!({}))
.expect("construct admitted final tools/list parameters");
let response = client
.request_with_raw_result(&cx, "tools/list", Some(parameters))
.await
.expect("equivalent numeric response ID is admitted");
assert_eq!(
response.raw_result.as_deref(),
Some(r#"{"firstExtra":"a","number":1.20e+4,"lastExtra":"z"}"#),
"raw result ordering and number spelling must remain peer-authored for ID {response_id}",
);
client.close(&cx).await.expect("close admitted client");
}
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_async_public_true_response_id_mismatch_closes_and_cannot_be_reused() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, mut peer_io) = async_websocket_pair();
write_server_text_frame(&mut peer_io, &raw_modern_discovery_source("1")).await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","id":3,"result":{"number":1.20e+4}}"#,
)
.await;
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("modern client connects before mismatch");
let parameters = client
.with_modern_request_metadata(serde_json::json!({}))
.expect("construct admitted final tools/list parameters");
let error = client
.request_with_raw_result(&cx, "tools/list", Some(parameters.clone()))
.await
.expect_err("true numeric mismatch is never admitted");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(client.closed, "mismatch terminalizes the connection");
assert!(client.close_settled, "mismatch settles a close attempt");
assert_eq!(
client.next_id, 3,
"the mismatched response never advances or aliases the active request state",
);
let reuse = client
.request_with_raw_result(&cx, "tools/list", Some(parameters))
.await
.expect_err("terminal client cannot consume a late response or send again");
assert_eq!(reuse.code, McpErrorCode::InternalError);
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_async_modern_raw_control_and_server_methods_reject_before_contact() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, mut peer_io) = async_websocket_pair();
write_server_text_frame(&mut peer_io, &raw_modern_discovery_source("1")).await;
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("modern client connects before raw admission checks");
let next_id = client.next_id;
for method in ["initialize", "notifications/progress"] {
let error = client
.request_with_raw_result(&cx, method, Some(serde_json::json!({})))
.await
.expect_err("control and server-only methods are never client raw requests");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(
client.next_id, next_id,
"{method} made no transport contact"
);
}
client.close(&cx).await.expect("close rejected raw client");
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_async_cancellation_returns_promptly_when_peer_suppresses_terminal_response() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let (ready_sender, mut ready_receiver) = oneshot::channel();
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(discover) = server
.recv(&peer_cx)
.await
.expect("peer receives discovery")
else {
panic!("client must discover before an ordinary request");
};
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
discover.id.expect("discovery has an ID"),
async_modern_discovery_result(),
)),
)
.await
.expect("peer sends discovery result");
let JsonRpcMessage::Request(request) = server
.recv(&peer_cx)
.await
.expect("peer receives pending request")
else {
panic!("pending client operation must be a request");
};
ready_sender
.send(
&peer_cx,
request.id.clone().expect("pending request has an ID"),
)
.expect("signal pending request");
let JsonRpcMessage::Request(cancellation) = server
.recv(&peer_cx)
.await
.expect("peer receives cancellation control")
else {
panic!("client cancellation must be a notification request");
};
let CancellationWireMessage::Modern2026 { params, .. } =
CancellationWireMessage::decode(
ProtocolEra::Modern2026,
CancellationSender::Client,
&cancellation,
)
.expect("modern cancellation control is admitted")
else {
panic!("modern peer cannot receive an exact-2024 cancellation");
};
assert!(
params
.request_id
.correlates_with(&request.id.expect("request ID"))
);
let JsonRpcMessage::Request(next) = server
.recv(&peer_cx)
.await
.expect("peer receives the reused connection request")
else {
panic!("reused client operation must be a request");
};
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
next.id.expect("reused request has an ID"),
serde_json::json!({"reused_after_silent_cancel": true}),
)),
)
.await
.expect("peer writes reused response after suppressing terminal response");
})
.expect("spawn silent-terminal WebSocket peer");
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("client negotiates before pending cancellation");
let parameters = client
.with_modern_request_metadata(serde_json::json!({}))
.expect("construct admitted pending request");
let request_cx = Cx::for_testing();
let request_cx_for_task = request_cx.clone();
let mut pending = cx
.spawn(move |_| async move {
let result = client
.request_with_raw_result(
&request_cx_for_task,
"tools/list",
Some(parameters),
)
.await;
(client, result)
})
.expect("spawn pending client request");
let _request_id = ready_receiver
.recv(&cx)
.await
.expect("peer observed committed request");
request_cx.set_cancel_requested(true);
let (mut client, result) = pending
.join(&cx)
.await
.expect("pending client task completes after cancellation");
let error = result.expect_err("silent peer cannot turn cancellation into a response");
assert_eq!(error.code, McpErrorCode::RequestCancelled);
assert!(
!client.closed,
"silent cancellation keeps the connection reusable"
);
let next_parameters = client
.with_modern_request_metadata(serde_json::json!({}))
.expect("construct reused admitted request");
let reused = client
.request_with_raw_result(&cx, "tools/list", Some(next_parameters))
.await
.expect("suppressed terminal response does not block the next request");
assert_eq!(
reused.response.result,
Some(serde_json::json!({"reused_after_silent_cancel": true}))
);
client
.close(&cx)
.await
.expect("client closes after proving silent-server reuse");
assert!(matches!(peer.join(&cx).await, Ok(())));
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_async_cancellation_bounds_a_stalled_control_send_without_detaching_it() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = stalled_cancellation_async_websocket_pair();
let (ready_sender, mut ready_receiver) = oneshot::channel();
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(discover) = server
.recv(&peer_cx)
.await
.expect("peer receives discovery")
else {
panic!("client must discover before an ordinary request");
};
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
discover.id.expect("discovery has an ID"),
async_modern_discovery_result(),
)),
)
.await
.expect("peer sends discovery result");
let JsonRpcMessage::Request(request) = server
.recv(&peer_cx)
.await
.expect("peer receives committed request")
else {
panic!("pending client operation must be a request");
};
ready_sender
.send(&peer_cx, request.id.expect("request has an ID"))
.expect("signal committed request");
assert!(matches!(
server.recv(&peer_cx).await,
Err(TransportError::Closed)
));
})
.expect("spawn stalled-control peer");
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("client negotiates before stalled cancellation");
let params = client
.with_modern_request_metadata(serde_json::json!({}))
.expect("build pending request metadata");
let request_cx = Cx::for_testing();
let request_cx_for_task = request_cx.clone();
let mut pending = cx
.spawn(move |_| async move {
let result = client
.request_with_raw_result(&request_cx_for_task, "tools/list", Some(params))
.await;
(client, result)
})
.expect("spawn cancellable request");
let _request_id = ready_receiver
.recv(&cx)
.await
.expect("peer observed committed request");
request_cx.set_cancel_requested(true);
let (client, result) = pending
.join(&cx)
.await
.expect("bounded cancellation task completes");
let error = result.expect_err("stalled cancellation write cannot hang the caller");
assert_eq!(error.code, McpErrorCode::InternalError);
assert_eq!(error.message, "Request timed out");
assert!(
client.closed,
"timeout terminalizes the unsendable connection"
);
assert!(
matches!(peer.join(&cx).await, Ok(())),
"the timed-out send was dropped and no detached writer remains"
);
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_async_cancellation_discards_late_response_before_next_request_response() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let (ready_sender, mut ready_receiver) = oneshot::channel();
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(discover) = server
.recv(&peer_cx)
.await
.expect("peer receives discovery")
else {
panic!("client must discover before an ordinary request");
};
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
discover.id.expect("discovery has an ID"),
async_modern_discovery_result(),
)),
)
.await
.expect("peer sends discovery result");
let JsonRpcMessage::Request(cancelled_request) = server
.recv(&peer_cx)
.await
.expect("peer receives cancellable request")
else {
panic!("client operation must be a request");
};
let cancelled_id = cancelled_request.id.expect("cancellable request has an ID");
ready_sender
.send(&peer_cx, cancelled_id.clone())
.expect("signal committed request");
let JsonRpcMessage::Request(cancellation) = server
.recv(&peer_cx)
.await
.expect("peer receives cancellation control")
else {
panic!("client cancellation must be a notification request");
};
let CancellationWireMessage::Modern2026 { params, .. } =
CancellationWireMessage::decode(
ProtocolEra::Modern2026,
CancellationSender::Client,
&cancellation,
)
.expect("modern cancellation control is admitted")
else {
panic!("modern peer cannot receive an exact-2024 cancellation");
};
assert!(params.request_id.correlates_with(&cancelled_id));
let JsonRpcMessage::Request(next) = server
.recv(&peer_cx)
.await
.expect("peer receives request after cancellation")
else {
panic!("reused client operation must be a request");
};
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
cancelled_id,
serde_json::json!({"late": true}),
)),
)
.await
.expect("peer writes intentionally late cancelled response");
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
next.id.expect("reused request has an ID"),
serde_json::json!({"reused_after_late_response": true}),
)),
)
.await
.expect("peer writes correlated reused response");
})
.expect("spawn late-response WebSocket peer");
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("client negotiates before late-response cancellation");
let parameters = client
.with_modern_request_metadata(serde_json::json!({}))
.expect("construct admitted cancellable request");
let request_cx = Cx::for_testing();
let request_cx_for_task = request_cx.clone();
let mut pending = cx
.spawn(move |_| async move {
let result = client
.request_with_raw_result(
&request_cx_for_task,
"tools/list",
Some(parameters),
)
.await;
(client, result)
})
.expect("spawn cancellable client request");
let _request_id = ready_receiver
.recv(&cx)
.await
.expect("peer observed committed request");
request_cx.set_cancel_requested(true);
let (mut client, result) = pending
.join(&cx)
.await
.expect("cancelled client request returns without a terminal response");
assert_eq!(
result
.expect_err("cancellation wins before a peer response")
.code,
McpErrorCode::RequestCancelled
);
let next_parameters = client
.with_modern_request_metadata(serde_json::json!({}))
.expect("construct request after late response tombstone");
let reused = client
.request_with_raw_result(&cx, "tools/list", Some(next_parameters))
.await
.expect("matching late response is discarded before the next response");
assert_eq!(
reused.response.result,
Some(serde_json::json!({"reused_after_late_response": true}))
);
assert!(!client.closed, "tombstone discard retains the connection");
client
.close(&cx)
.await
.expect("client closes after late-response reuse proof");
assert!(matches!(peer.join(&cx).await, Ok(())));
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_async_public_mrtr_retries_through_real_split_transport() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, mut peer_io) = async_websocket_pair();
write_server_text_frame(&mut peer_io, &raw_modern_discovery_source("1")).await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"input_required","inputRequests":{"roots":{"method":"roots/list"}},"requestState":"retry-1"}}"#,
)
.await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","id":3,"result":{"resultType":"complete","content":[],"isError":false}}"#,
)
.await;
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("modern client connects before MRTR");
let result = client
.call_tool_with_mrtr_retry(
&cx,
Instant::now() + Duration::from_secs(1),
"retry-tool",
serde_json::json!({"round": 1}),
|_| {
Ok(BTreeMap::from([(
"roots".to_owned(),
serde_json::json!({"roots": []}),
)]))
},
)
.await
.expect("MRTR retry consumes the peer input request and final result");
assert!(matches!(result, FinalCoreResult::ToolsCall { .. }));
client.close(&cx).await.expect("close MRTR client");
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn rh5_websocket_resource_mrtr_rejects_one_wrong_input_response_without_retry_or_state_loss() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, mut peer_io) = async_websocket_pair();
write_server_text_frame(&mut peer_io, &raw_modern_discovery_source("1")).await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"input_required","inputRequests":{"roots":{"method":"roots/list"}},"requestState":"resource-round"}}"#,
)
.await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","id":3,"result":{"reused":true}}"#,
)
.await;
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("modern client connects before one-field MRTR negative");
let next_id_before = client.next_id;
let error = client
.read_resource_with_mrtr_retry(
&cx,
Instant::now() + Duration::from_secs(1),
"file:///typed.txt",
|_| {
Ok(BTreeMap::from([(
"wrong".to_owned(),
serde_json::json!({}),
)]))
},
)
.await
.expect_err("changing only inputResponses key must reject before a retry send");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(
client.next_id,
next_id_before + 1,
"the rejected response must not allocate a continuation ID"
);
assert!(
!client.closed,
"local MRTR admission failure retains the socket"
);
let parameters = client
.with_modern_request_metadata(serde_json::json!({}))
.expect("construct a request after rejected MRTR responses");
let reused = client
.request_with_raw_result(&cx, "tools/list", Some(parameters))
.await
.expect("unchanged connection state permits the next request");
assert_eq!(
reused.response.result,
Some(serde_json::json!({"reused": true}))
);
client.close(&cx).await.expect("close reusable MRTR client");
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_prompt_mrtr_round_bound_stops_before_a_fifth_continuation_and_retains_reuse() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, mut peer_io) = async_websocket_pair();
write_server_text_frame(&mut peer_io, &raw_modern_discovery_source("1")).await;
for request_id in 2..=(MAX_MRTR_CONTINUATION_ROUNDS as i64 + 2) {
write_server_text_frame(
&mut peer_io,
&format!(
r#"{{"jsonrpc":"2.0","id":{request_id},"result":{{"resultType":"input_required","inputRequests":{{"roots":{{"method":"roots/list"}}}},"requestState":"round-bound"}}}}"#
),
)
.await;
}
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","id":7,"result":{"reused_after_round_bound":true}}"#,
)
.await;
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("modern client connects before MRTR round bound");
let callback_count = Arc::new(AtomicUsize::new(0));
let callback_count_for_response = Arc::clone(&callback_count);
let error = client
.get_prompt_with_mrtr_retry(
&cx,
Instant::now() + Duration::from_secs(1),
"round-bound",
HashMap::new(),
move |_| {
callback_count_for_response.fetch_add(1, Ordering::SeqCst);
Ok(BTreeMap::from([(
"roots".to_owned(),
serde_json::json!({"roots": []}),
)]))
},
)
.await
.expect_err("a fifth input_required response exceeds the shared MRTR round bound");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert_eq!(
callback_count.load(Ordering::SeqCst),
MAX_MRTR_CONTINUATION_ROUNDS,
"the fifth response must not invoke the responder"
);
assert_eq!(client.next_id, 7, "no sixth continuation ID is allocated");
assert!(
!client.closed,
"MRTR round admission leaves the socket reusable"
);
let parameters = client
.with_modern_request_metadata(serde_json::json!({}))
.expect("construct a request after the MRTR round bound");
let reused = client
.request_with_raw_result(&cx, "tools/list", Some(parameters))
.await
.expect("round-bound rejection preserves the WebSocket connection");
assert_eq!(
reused.response.result,
Some(serde_json::json!({"reused_after_round_bound": true}))
);
client.close(&cx).await.expect("close round-bound client");
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_async_public_typed_subscription_collects_ack_event_and_terminal() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
for response_id in ["2", "2.0", "2e0"] {
let (client_io, mut peer_io) = async_websocket_pair();
write_server_text_frame(&mut peer_io, &raw_modern_discovery_source("1e0")).await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{"_meta":{"io.modelcontextprotocol/subscriptionId":2},"notifications":{"toolsListChanged":true}}}"#,
)
.await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","method":"notifications/tools/list_changed","params":{}}"#,
)
.await;
write_server_text_frame(
&mut peer_io,
&format!(
r#"{{"jsonrpc":"2.0","id":{response_id},"result":{{"resultType":"complete","_meta":{{"io.modelcontextprotocol/subscriptionId":2}}}}}}"#
),
)
.await;
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("modern client connects before subscription");
let collector = client
.listen_subscriptions_typed(
&cx,
SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
},
)
.await
.expect("typed subscription retains its admitted stream for every numeric ID spelling");
assert_eq!(collector.notifications.len(), 1);
client.close(&cx).await.expect("close subscription client");
}
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_incremental_catalog_listener_routes_ack_list_changed_and_terminal() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, mut peer_io) = async_websocket_pair();
write_server_text_frame(&mut peer_io, &raw_modern_discovery_source("1e0")).await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{"_meta":{"io.modelcontextprotocol/subscriptionId":2},"notifications":{"toolsListChanged":true}}}"#,
)
.await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","method":"notifications/tools/list_changed","params":{}}"#,
)
.await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/subscriptionId":2}}}"#,
)
.await;
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("modern client connects before incremental listen");
client
.open_subscriptions_listener(
&cx,
SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
},
)
.await
.expect("incremental WebSocket listen commits one catalog subscription");
let cancellation = McpRequestCancellation::new();
assert!(matches!(
client
.next_subscription_event(&cx, &cancellation)
.await
.expect("incremental WebSocket ingress routes the acknowledgement"),
StdioSubscriptionEvent::Acknowledged(ref filter)
if filter.tools_list_changed == Some(true)
));
assert!(matches!(
client
.next_subscription_event(&cx, &cancellation)
.await
.expect("incremental WebSocket ingress routes the catalog event"),
StdioSubscriptionEvent::Notification(ServerNotification::ToolsListChanged(None))
));
assert!(matches!(
client
.next_subscription_event(&cx, &cancellation)
.await
.expect("incremental WebSocket ingress routes the terminal response"),
StdioSubscriptionEvent::Terminal
));
assert!(
client.live_catalog_subscription.is_none(),
"terminal completion releases only the completed live listener"
);
client
.close(&cx)
.await
.expect("close incremental subscription client");
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_incremental_catalog_listener_keeps_issuing_tools_list() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, mut peer_io) = async_websocket_pair();
write_server_text_frame(&mut peer_io, &raw_modern_discovery_source("1e0")).await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{"_meta":{"io.modelcontextprotocol/subscriptionId":2},"notifications":{"toolsListChanged":true}}}"#,
)
.await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","method":"notifications/tools/list_changed","params":{}}"#,
)
.await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/subscriptionId":2}}}"#,
)
.await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","id":3,"result":{"resultType":"complete","tools":[],"ttlMs":0,"cacheScope":"private"}}"#,
)
.await;
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("modern client connects before interleaved listen");
client
.open_subscriptions_listener(
&cx,
SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
},
)
.await
.expect("listen stays live while this client issues another request");
let cancellation = McpRequestCancellation::new();
assert!(matches!(
client
.next_subscription_event(&cx, &cancellation)
.await
.expect("acknowledgement arrives before the interleaved tools/list"),
StdioSubscriptionEvent::Acknowledged(_)
));
let listed = client
.list_tools_with_cancellation(&cx, &cancellation, None)
.await
.expect("the same client must complete tools/list while listen is live");
assert!(matches!(
listed,
CoreResult::Final(FinalCoreResult::ToolsList { .. })
));
assert!(matches!(
client
.next_subscription_event(&cx, &cancellation)
.await
.expect("catalog events queued during tools/list stay request-owned"),
StdioSubscriptionEvent::Notification(ServerNotification::ToolsListChanged(None))
));
assert!(matches!(
client
.next_subscription_event(&cx, &cancellation)
.await
.expect("a listen terminal parked during tools/list remains available"),
StdioSubscriptionEvent::Terminal
));
client
.close(&cx)
.await
.expect("close interleaved subscription client");
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_typed_list_tools_with_cancellation_rejects_before_contact() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let listed = Arc::new(AtomicBool::new(false));
let peer_listed = Arc::clone(&listed);
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(discover) = server
.recv(&peer_cx)
.await
.expect("peer receives discovery")
else {
panic!("client must discover before a cancelled tools/list");
};
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
discover.id.expect("discovery has an ID"),
async_modern_discovery_result(),
)),
)
.await
.expect("peer sends discovery result");
if let Ok(JsonRpcMessage::Request(request)) = server.recv(&peer_cx).await
&& request.method == "tools/list"
{
peer_listed.store(true, Ordering::SeqCst);
}
})
.expect("spawn cancelled-tools/list WebSocket peer");
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("modern client connects before a cancelled tools/list");
let cancellation = McpRequestCancellation::new();
cancellation.cancel();
let error = client
.list_tools_with_cancellation(&cx, &cancellation, None)
.await
.expect_err("an already-cancelled domain must reject before tools/list");
assert_eq!(error.code, McpErrorCode::RequestCancelled);
let resource = client
.read_resource_with_cancellation(&cx, &cancellation, "info://pre-send")
.await
.expect_err("an already-cancelled domain must reject before resources/read");
assert_eq!(resource.code, McpErrorCode::RequestCancelled);
let prompt = client
.get_prompt_with_cancellation(
&cx,
&cancellation,
"pre-send",
std::collections::HashMap::new(),
)
.await
.expect_err("an already-cancelled domain must reject before prompts/get");
assert_eq!(prompt.code, McpErrorCode::RequestCancelled);
let ping = client
.ping_with_cancellation(&cx, &cancellation)
.await
.expect_err("an already-cancelled domain must reject before ping");
assert_eq!(ping.code, McpErrorCode::RequestCancelled);
client
.close(&cx)
.await
.expect("close cancelled-tools/list client");
peer.abort();
let _ = peer.join(&cx).await;
assert!(
!listed.load(Ordering::SeqCst),
"cancelled typed verbs must not write a tools/list frame"
);
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_incremental_catalog_listener_rejects_second_open_and_empty_filter() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, mut peer_io) = async_websocket_pair();
write_server_text_frame(&mut peer_io, &raw_modern_discovery_source("1e0")).await;
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("modern client connects before reservation checks");
let error = client
.open_subscriptions_listener(&cx, SubscriptionFilter::default())
.await
.expect_err("an empty filter is not a catalog subscription");
assert_eq!(error.code, McpErrorCode::InvalidParams);
let filter = SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
};
client
.open_subscriptions_listener(&cx, filter.clone())
.await
.expect("the first incremental listen reserves ingress");
let error = client
.open_subscriptions_listener(&cx, filter.clone())
.await
.expect_err("a second incremental listen must fail closed");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
let error = client
.listen_subscriptions_typed(&cx, filter)
.await
.expect_err("collect-to-terminal listen cannot steal a live incremental listener");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
client
.close(&cx)
.await
.expect("close reservation-check client");
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_incremental_catalog_listener_rejects_event_before_acknowledgement() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, mut peer_io) = async_websocket_pair();
write_server_text_frame(&mut peer_io, &raw_modern_discovery_source("1e0")).await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","method":"notifications/tools/list_changed","params":{}}"#,
)
.await;
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("modern client connects before the unacknowledged event");
client
.open_subscriptions_listener(
&cx,
SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
},
)
.await
.expect("the live listener commits before the unacknowledged event is consumed");
let error = client
.next_subscription_event(&cx, &McpRequestCancellation::new())
.await
.expect_err("an event before acknowledgement must fail closed");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(error.message.contains("before acknowledgement"));
client
.close(&cx)
.await
.expect("close before-ack subscription client");
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_async_subscription_ack_id_mismatch_is_terminal_and_cannot_consume_later_frames() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, mut peer_io) = async_websocket_pair();
write_server_text_frame(&mut peer_io, &raw_modern_discovery_source("1")).await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{"_meta":{"io.modelcontextprotocol/subscriptionId":3},"notifications":{"toolsListChanged":true}}}"#,
)
.await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{"_meta":{"io.modelcontextprotocol/subscriptionId":2},"notifications":{"toolsListChanged":true}}}"#,
)
.await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/subscriptionId":2}}}"#,
)
.await;
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("modern client connects before acknowledgement mismatch");
let error = client
.listen_subscriptions_typed(
&cx,
SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
},
)
.await
.expect_err("one-dimension acknowledgement ID mismatch is terminal");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(client.closed);
assert!(client.close_settled);
let next_id = client.next_id;
let reuse = client
.listen_subscriptions_typed(
&cx,
SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
},
)
.await
.expect_err("terminal listener cannot consume later valid-looking frames");
assert_eq!(reuse.code, McpErrorCode::InternalError);
assert_eq!(
client.next_id, next_id,
"terminal reuse makes no transport contact"
);
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_async_subscription_terminal_id_mismatch_is_terminal_and_cannot_be_reused() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, mut peer_io) = async_websocket_pair();
write_server_text_frame(&mut peer_io, &raw_modern_discovery_source("1")).await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{"_meta":{"io.modelcontextprotocol/subscriptionId":2},"notifications":{"toolsListChanged":true}}}"#,
)
.await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/subscriptionId":3}}}"#,
)
.await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/subscriptionId":2}}}"#,
)
.await;
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("modern client connects before terminal ID mismatch");
let error = client
.listen_subscriptions_typed(
&cx,
SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
},
)
.await
.expect_err("one-dimension terminal subscription ID mismatch is terminal");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(client.closed);
assert!(client.close_settled);
let next_id = client.next_id;
let reuse = client
.request_with_raw_result(&cx, "tools/list", Some(serde_json::json!({})))
.await
.expect_err("terminal listener cannot consume its later valid-looking response");
assert_eq!(reuse.code, McpErrorCode::InternalError);
assert_eq!(
client.next_id, next_id,
"terminal reuse makes no transport contact"
);
});
}
#[cfg(all(unix, feature = "tasks", feature = "websocket-experimental"))]
#[test]
fn websocket_async_public_tasks_get_uses_negotiated_extension_transport() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, mut peer_io) = async_websocket_pair();
write_server_text_frame(
&mut peer_io,
&modern_tasks_discovery_response("websocket-tasks", serde_json::json!({})),
)
.await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","taskId":"task-1","status":"input_required","createdAt":"2026-07-28T00:00:00Z","lastUpdatedAt":"2026-07-28T00:00:00Z","ttlMs":null,"inputRequests":{}}}"#,
)
.await;
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("Tasks discovery negotiates on the real WebSocket transport");
let task = client
.get_task_final(
&cx,
FinalTaskId::parse("task-1").expect("valid final task ID"),
)
.await
.expect("typed tasks/get traverses the admitted extension transport");
assert!(matches!(task.task, FinalTask::InputRequired { .. }));
client.close(&cx).await.expect("close Tasks client");
});
}
#[cfg(all(unix, feature = "tasks", feature = "websocket-experimental"))]
#[test]
fn websocket_async_tasks_get_task_id_mismatch_is_terminal_and_cannot_be_reused() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, mut peer_io) = async_websocket_pair();
write_server_text_frame(
&mut peer_io,
&modern_tasks_discovery_response("websocket-task-mismatch", serde_json::json!({})),
)
.await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","taskId":"task-2","status":"input_required","createdAt":"2026-07-28T00:00:00Z","lastUpdatedAt":"2026-07-28T00:00:00Z","ttlMs":null,"inputRequests":{}}}"#,
)
.await;
write_server_text_frame(
&mut peer_io,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","taskId":"task-1","status":"input_required","createdAt":"2026-07-28T00:00:00Z","lastUpdatedAt":"2026-07-28T00:00:00Z","ttlMs":null,"inputRequests":{}}}"#,
)
.await;
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("Tasks discovery negotiates before taskId mismatch");
let error = client
.get_task_final(
&cx,
FinalTaskId::parse("task-1").expect("valid requested task ID"),
)
.await
.expect_err("one-dimension tasks/get taskId mismatch is terminal");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(client.closed);
assert!(client.close_settled);
let next_id = client.next_id;
let reuse = client
.get_task_final(
&cx,
FinalTaskId::parse("task-1").expect("valid requested task ID"),
)
.await
.expect_err("terminal tasks/get cannot consume later valid-looking frames");
assert_eq!(reuse.code, McpErrorCode::InternalError);
assert_eq!(
client.next_id, next_id,
"terminal reuse makes no transport contact"
);
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_async_public_client_uses_real_split_transport_and_retains_result_source() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(discover) = server
.recv(&peer_cx)
.await
.expect("peer receives final discovery")
else {
panic!("client must begin with final discovery");
};
assert_eq!(discover.method, SERVER_DISCOVER_METHOD);
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
discover.id.expect("discovery has an ID"),
async_modern_discovery_result(),
)),
)
.await
.expect("peer sends discovery response");
let JsonRpcMessage::Request(request) = server
.recv(&peer_cx)
.await
.expect("peer receives one public client request")
else {
panic!("public client request must be a JSON-RPC request");
};
assert_eq!(request.method, "tools/list");
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
request.id.expect("public request has an ID"),
serde_json::json!({"peer": "exact"}),
)),
)
.await
.expect("peer sends public response");
let JsonRpcMessage::Request(completion) = server
.recv(&peer_cx)
.await
.expect("peer receives typed completion request")
else {
panic!("typed completion must be a JSON-RPC request");
};
assert_eq!(completion.method, "completion/complete");
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
completion.id.expect("completion has an ID"),
serde_json::json!({
"resultType": "complete",
"completion": {
"values": ["staging"],
"total": 1,
"hasMore": false
}
}),
)),
)
.await
.expect("peer sends typed completion response");
})
.expect("spawn WebSocket peer");
let mut client = ClientBuilder::new()
.client_info("async-websocket-client", "1.0")
.protocol_plan(ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly))
.connect_websocket_with_cx(&cx, AsyncWsClientTransport::from_upgraded(client_io))
.await
.expect("public async client negotiates real WebSocket transport");
assert_eq!(client.selected_protocol_era(), ProtocolEra::Modern2026);
let raw_parameters = client
.with_modern_request_metadata(serde_json::json!({}))
.expect("construct admitted public raw request parameters");
let response = client
.request_with_raw_result(&cx, "tools/list", Some(raw_parameters))
.await
.expect("public request uses the negotiated transport");
assert_eq!(response.raw_result.as_deref(), Some(r#"{"peer":"exact"}"#));
let completion = client
.complete(
&cx,
CompletionParams {
reference: CompletionReference::Prompt {
name: "deploy".to_owned(),
},
argument: CompletionArgument {
name: "environment".to_owned(),
value: "sta".to_owned(),
},
context: None,
},
)
.await
.expect("typed completion uses the negotiated transport");
assert!(matches!(
completion,
CoreResult::Final(FinalCoreResult::Completion { .. })
));
client.close(&cx).await.expect("close client transport");
assert!(matches!(peer.join(&cx).await, Ok(())));
});
}
#[cfg(all(not(feature = "legacy-2024-11-05"), feature = "websocket-experimental"))]
#[test]
fn websocket_async_feature_off_auto_rejects_before_factory_contact() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let factory_calls = Arc::new(AtomicUsize::new(0));
let factory_calls_for_factory = Arc::clone(&factory_calls);
let error =
WebSocketClient::<asupersync::net::tcp::VirtualTcpStream>::connect_auto_with_cx(
&cx,
async_websocket_client_info(),
ClientCapabilities::default(),
move |_| {
factory_calls_for_factory.fetch_add(1, Ordering::SeqCst);
async {
Err::<
AsyncWsClientTransport<asupersync::net::tcp::VirtualTcpStream>,
McpError,
>(McpError::internal_error(
"feature-off Auto factory must not run",
))
}
},
)
.await
.expect_err("feature-off Auto must reject before opening a WebSocket transport");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert!(
error
.message
.contains("FeatureUnavailable: legacy-2024-11-05 is compiled out")
);
assert_eq!(factory_calls.load(Ordering::SeqCst), 0);
});
}
#[cfg(all(feature = "legacy-2024-11-05", feature = "websocket-experimental"))]
#[test]
fn websocket_async_auto_wrong_error_never_attempts_a_fresh_legacy_transport() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io, close_frames) = close_counting_async_websocket_pair();
let contacts = Arc::new(AtomicUsize::new(0));
let peer_contacts = Arc::clone(&contacts);
let close_observed = Arc::new(AtomicBool::new(false));
let peer_close_observed = Arc::clone(&close_observed);
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(discover) = server
.recv(&peer_cx)
.await
.expect("peer receives Auto discovery")
else {
panic!("Auto must send discovery first");
};
peer_contacts.fetch_add(1, Ordering::SeqCst);
assert_eq!(discover.method, SERVER_DISCOVER_METHOD);
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::error(
Some(discover.id.expect("discovery has an ID")),
JsonRpcError {
code: (-32600).into(),
message: "peer-specific refusal text".to_owned(),
data: None,
},
)),
)
.await
.expect("peer refuses discovery");
assert!(matches!(
server.recv(&peer_cx).await,
Err(TransportError::Closed)
));
peer_close_observed.store(true, Ordering::Release);
})
.expect("spawn refusing WebSocket peer");
let factory_calls = Arc::new(AtomicUsize::new(0));
let factory_calls_for_factory = Arc::clone(&factory_calls);
let mut first_transport = Some(AsyncWsClientTransport::from_upgraded(client_io));
let error = WebSocketClient::connect_auto_with_cx(
&cx,
async_websocket_client_info(),
ClientCapabilities::default(),
move |_| {
factory_calls_for_factory.fetch_add(1, Ordering::SeqCst);
let transport = first_transport.take();
async move {
transport.ok_or_else(|| {
McpError::internal_error("unexpected second Auto transport attempt")
})
}
},
)
.await
.expect_err("same refusal text with a non-MethodNotFound code is terminal");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert_eq!(factory_calls.load(Ordering::SeqCst), 1);
assert_eq!(contacts.load(Ordering::SeqCst), 1);
assert!(matches!(peer.join(&cx).await, Ok(())));
assert!(
close_observed.load(Ordering::Acquire),
"the refused transport is closed without creating a fallback transport"
);
assert_eq!(
close_frames.load(Ordering::SeqCst),
1,
"terminal refusal elects exactly one shared split close"
);
});
}
#[cfg(all(feature = "legacy-2024-11-05", feature = "websocket-experimental"))]
#[test]
fn websocket_async_auto_uses_one_fresh_legacy_transport_after_recognized_refusal() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (first_client_io, first_server_io, first_close_frames) =
close_counting_async_websocket_pair();
let (legacy_client_io, legacy_server_io, legacy_close_frames) =
close_counting_async_websocket_pair();
let first_contacts = Arc::new(AtomicUsize::new(0));
let first_contacts_for_peer = Arc::clone(&first_contacts);
let mut first_peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(first_server_io);
let JsonRpcMessage::Request(discover) = server
.recv(&peer_cx)
.await
.expect("peer receives Auto discovery")
else {
panic!("Auto must send discovery first");
};
first_contacts_for_peer.fetch_add(1, Ordering::SeqCst);
assert_eq!(discover.method, SERVER_DISCOVER_METHOD);
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::error(
Some(discover.id.expect("discovery has an ID")),
JsonRpcError {
code: (-32601).into(),
message: "peer-specific refusal text".to_owned(),
data: None,
},
)),
)
.await
.expect("peer refuses discovery");
assert!(matches!(
server.recv(&peer_cx).await,
Err(TransportError::Closed)
));
})
.expect("spawn recognized-refusal WebSocket peer");
let legacy_contacts = Arc::new(AtomicUsize::new(0));
let legacy_contacts_for_peer = Arc::clone(&legacy_contacts);
let mut legacy_peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(legacy_server_io);
let JsonRpcMessage::Request(initialize) = server
.recv(&peer_cx)
.await
.expect("fresh peer receives exact legacy initialize")
else {
panic!("fresh connection must begin with exact legacy initialize");
};
legacy_contacts_for_peer.fetch_add(1, Ordering::SeqCst);
assert_eq!(initialize.method, "initialize");
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
initialize.id.expect("initialize has an ID"),
serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"serverInfo": {"name": "fresh-legacy", "version": "1.0"}
}),
)),
)
.await
.expect("fresh peer initializes exact legacy");
let JsonRpcMessage::Request(initialized) = server
.recv(&peer_cx)
.await
.expect("fresh peer receives initialized notification")
else {
panic!("exact legacy connection must send initialized notification");
};
assert_eq!(initialized.method, "notifications/initialized");
assert!(matches!(
server.recv(&peer_cx).await,
Err(TransportError::Closed)
));
})
.expect("spawn fresh exact-legacy WebSocket peer");
let factory_calls = Arc::new(AtomicUsize::new(0));
let factory_calls_for_factory = Arc::clone(&factory_calls);
let mut transports = VecDeque::from([
AsyncWsClientTransport::from_upgraded(first_client_io),
AsyncWsClientTransport::from_upgraded(legacy_client_io),
]);
let mut client = WebSocketClient::connect_auto_with_cx(
&cx,
async_websocket_client_info(),
ClientCapabilities::default(),
move |_| {
factory_calls_for_factory.fetch_add(1, Ordering::SeqCst);
let transport = transports.pop_front();
async move {
transport.ok_or_else(|| {
McpError::internal_error(
"Auto exceeded its two fresh transport attempts",
)
})
}
},
)
.await
.expect("recognized refusal retries only on a fresh exact legacy transport");
assert_eq!(client.selected_protocol_era(), ProtocolEra::Legacy2024);
assert_eq!(factory_calls.load(Ordering::SeqCst), 2);
assert_eq!(first_contacts.load(Ordering::SeqCst), 1);
assert_eq!(legacy_contacts.load(Ordering::SeqCst), 1);
assert_eq!(
first_close_frames.load(Ordering::SeqCst),
1,
"recognized refusal closes the first transport before fresh legacy admission"
);
client
.close(&cx)
.await
.expect("close fresh exact legacy client");
assert!(matches!(first_peer.join(&cx).await, Ok(())));
assert!(matches!(legacy_peer.join(&cx).await, Ok(())));
assert_eq!(
legacy_close_frames.load(Ordering::SeqCst),
1,
"the admitted fresh transport has one final split close"
);
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_async_failed_discovery_validation_closes_both_halves_and_keeps_error() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io, close_frames) = close_counting_async_websocket_pair();
let contacts = Arc::new(AtomicUsize::new(0));
let peer_contacts = Arc::clone(&contacts);
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(discover) = server
.recv(&peer_cx)
.await
.expect("peer receives Modern discovery")
else {
panic!("Modern connection must begin with discovery");
};
peer_contacts.fetch_add(1, Ordering::SeqCst);
assert_eq!(discover.method, SERVER_DISCOVER_METHOD);
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
discover.id.expect("discovery has an ID"),
serde_json::json!({
"resultType": "complete",
"supportedVersions": [MODERN_PROTOCOL_VERSION],
"capabilities": {},
"_meta": {},
"ttlMs": 0,
"cacheScope": "private"
}),
)),
)
.await
.expect("peer returns a structurally incomplete discovery result");
assert!(matches!(
server.recv(&peer_cx).await,
Err(TransportError::Closed)
));
})
.expect("spawn incomplete-discovery WebSocket peer");
let error = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect_err("incomplete discovery remains a handshake error");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert_eq!(
error.message,
"Modern WebSocket discovery response has no server identity"
);
assert_eq!(contacts.load(Ordering::SeqCst), 1);
assert!(matches!(peer.join(&cx).await, Ok(())));
assert_eq!(
close_frames.load(Ordering::SeqCst),
1,
"discovery validation failure elects exactly one shared split close"
);
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_async_handshake_wire_failure_closes_each_split_half_once() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io, close_frames) = close_counting_async_websocket_pair();
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(discover) = server
.recv(&peer_cx)
.await
.expect("peer receives discovery before wire failure")
else {
panic!("Modern connection must begin with discovery");
};
server
.send(
&peer_cx,
&JsonRpcMessage::Request(JsonRpcRequest::new(
"tools/list",
Some(serde_json::json!({})),
discover.id.expect("discovery has an ID"),
)),
)
.await
.expect("peer emits an invalid handshake envelope");
assert!(matches!(
server.recv(&peer_cx).await,
Err(TransportError::Closed)
));
})
.expect("spawn wire-failure WebSocket peer");
let error = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect_err("a non-response handshake envelope is terminal");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert_eq!(
error.message,
"WebSocket handshake received a non-response frame"
);
assert!(matches!(peer.join(&cx).await, Ok(())));
assert_eq!(
close_frames.load(Ordering::SeqCst),
1,
"wire failure elects exactly one shared split close"
);
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_async_session_admission_rejection_closes_each_split_half_once() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, _peer_io, close_frames) = close_counting_async_websocket_pair();
let (mut receiver, mut sender) =
AsyncWsClientTransport::from_upgraded(client_io).into_split();
let error = admit_websocket_session_or_close(
&mut receiver,
&mut sender,
&cx,
Err(McpError::internal_error(
"injected session admission rejection",
)),
)
.await
.expect_err("the production session-admission seam preserves its error");
assert_eq!(error.message, "injected session admission rejection");
assert_eq!(
close_frames.load(Ordering::SeqCst),
1,
"session rejection elects exactly one shared split close"
);
});
}
#[cfg(all(feature = "websocket-experimental", unix))]
#[test]
fn websocket_async_incompatible_generic_extension_closes_split_halves_after_discovery() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io, close_frames) = close_counting_async_websocket_pair();
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(discover) = server
.recv(&peer_cx)
.await
.expect("peer receives discovery before extension admission")
else {
panic!("modern connection must begin with discovery");
};
let source =
modern_raw_extension_discovery_response("one-sided-extension-peer", None);
let discovery: serde_json::Value =
serde_json::from_str(&source).expect("extension discovery source is JSON");
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
discover.id.expect("discovery has an ID"),
discovery["result"].clone(),
)),
)
.await
.expect("peer sends discovery that leaves an extension one-sided");
assert!(matches!(
server.recv(&peer_cx).await,
Err(TransportError::Closed)
));
})
.expect("spawn one-sided extension peer");
let (_extension_id, registry, discovery) = raw_extension_configuration(
ExtensionDirection::ClientToServer,
fastmcp_protocol::extensions::ExtensionFallbackPolicy::RejectOneSided,
);
let connection = ClientBuilder::new()
.protocol_plan(ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly))
.extension_registry(registry, discovery, || accept_raw_extension_settings)
.expect("frozen generic extension configuration is valid")
.connect_websocket_with_cx(&cx, AsyncWsClientTransport::from_upgraded(client_io))
.await;
let error = match connection {
Ok(_) => panic!("one-sided generic extension rejects after discovery"),
Err(error) => error,
};
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert!(matches!(peer.join(&cx).await, Ok(())));
assert_eq!(
close_frames.load(Ordering::SeqCst),
1,
"generic extension admission rejection settles the shared split writer exactly once"
);
});
}
#[cfg(all(feature = "websocket-experimental", unix))]
#[test]
fn clt_ext_raw_websocket_public_request_uses_bilateral_registry() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(discover) = server
.recv(&peer_cx)
.await
.expect("peer receives extension discovery")
else {
panic!("discovery must be a request");
};
assert_eq!(discover.method, SERVER_DISCOVER_METHOD);
assert_eq!(
discover.params.as_ref().expect("discovery has params")["_meta"]
[FINAL_CLIENT_CAPABILITIES_META_KEY]["extensions"]["com.example/raw"],
serde_json::json!({"mode": "raw"})
);
let source = modern_raw_extension_discovery_response(
"raw-extension-websocket-peer",
Some(serde_json::json!({"mode": "raw"})),
);
let discovery: serde_json::Value =
serde_json::from_str(&source).expect("extension discovery source is JSON");
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
discover.id.expect("discovery has an ID"),
discovery["result"].clone(),
)),
)
.await
.expect("peer replies to discovery");
let JsonRpcMessage::Request(extension) = server
.recv(&peer_cx)
.await
.expect("peer receives admitted extension request")
else {
panic!("extension call must be a request");
};
assert_eq!(extension.method, "example/echo");
assert_eq!(
extension.params.as_ref().expect("extension has params")["input"],
"ok"
);
assert_eq!(
extension.params.as_ref().expect("extension has params")["_meta"]
[FINAL_CLIENT_CAPABILITIES_META_KEY]["extensions"]["com.example/raw"],
serde_json::json!({"mode": "raw"})
);
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
extension.id.expect("extension request has an ID"),
serde_json::json!({"echoed": {"input": "ok"}}),
)),
)
.await
.expect("peer replies to extension request");
})
.expect("spawn extension WebSocket peer");
let (extension_id, registry, discovery) = raw_extension_configuration(
ExtensionDirection::ClientToServer,
fastmcp_protocol::extensions::ExtensionFallbackPolicy::RejectOneSided,
);
let mut client = ClientBuilder::new()
.protocol_plan(ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly))
.extension_registry(registry, discovery, || accept_raw_extension_settings)
.expect("frozen generic extension configuration is valid")
.connect_websocket_with_cx(&cx, AsyncWsClientTransport::from_upgraded(client_io))
.await
.expect("WebSocket discovery bilaterally negotiates the extension");
let result = client
.request_final_extension(
&cx,
&extension_id,
"example/echo",
serde_json::json!({"input": "ok"}),
)
.await
.expect("public WebSocket extension request uses admitted source path");
assert_eq!(result["echoed"]["input"], "ok");
client
.close(&cx)
.await
.expect("close reusable extension client");
assert!(matches!(peer.join(&cx).await, Ok(())));
});
}
#[cfg(all(feature = "websocket-experimental", unix))]
#[test]
fn rh5_websocket_extension_wrong_direction_rejects_before_contact_or_id_allocation() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(discover) = server
.recv(&peer_cx)
.await
.expect("peer receives discovery")
else {
panic!("discovery must be a request");
};
let source = modern_raw_extension_discovery_response(
"wrong-direction-websocket-peer",
Some(serde_json::json!({"mode": "raw"})),
);
let discovery: serde_json::Value =
serde_json::from_str(&source).expect("extension discovery source is JSON");
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
discover.id.expect("discovery has an ID"),
discovery["result"].clone(),
)),
)
.await
.expect("peer replies to discovery");
assert!(matches!(
server.recv(&peer_cx).await,
Err(TransportError::Closed)
));
})
.expect("spawn no-contact wrong-direction peer");
// This differs from the admitted configuration only by the
// descriptor direction. The rejected call must not produce ID 2.
let (extension_id, registry, discovery) = raw_extension_configuration(
ExtensionDirection::ServerToClient,
fastmcp_protocol::extensions::ExtensionFallbackPolicy::RejectOneSided,
);
let mut client = ClientBuilder::new()
.protocol_plan(ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly))
.extension_registry(registry, discovery, || accept_raw_extension_settings)
.expect("wrong-direction descriptor remains discoverable")
.connect_websocket_with_cx(&cx, AsyncWsClientTransport::from_upgraded(client_io))
.await
.expect("bilateral discovery succeeds before direction admission");
let next_id_before = client.next_id;
let error = client
.request_final_extension(&cx, &extension_id, "example/echo", serde_json::json!({}))
.await
.expect_err("server-to-client extension method cannot cross client request seam");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(
client.next_id, next_id_before,
"RH-5 rejection allocates no ID"
);
client.close(&cx).await.expect("close no-contact client");
assert!(matches!(peer.join(&cx).await, Ok(())));
});
}
#[cfg(all(feature = "websocket-experimental", unix))]
#[test]
fn websocket_extension_unnegotiated_settings_rejects_without_contact() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(discover) = server
.recv(&peer_cx)
.await
.expect("peer receives discovery")
else {
panic!("discovery must be a request");
};
let source =
modern_raw_extension_discovery_response("inactive-websocket-peer", None);
let discovery: serde_json::Value =
serde_json::from_str(&source).expect("extension discovery source is JSON");
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
discover.id.expect("discovery has an ID"),
discovery["result"].clone(),
)),
)
.await
.expect("peer replies to discovery");
assert!(matches!(
server.recv(&peer_cx).await,
Err(TransportError::Closed)
));
})
.expect("spawn no-contact inactive-extension peer");
let (extension_id, registry, discovery) = raw_extension_configuration(
ExtensionDirection::ClientToServer,
fastmcp_protocol::extensions::ExtensionFallbackPolicy::ClientInactiveFallback,
);
let mut client = ClientBuilder::new()
.protocol_plan(ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly))
.extension_registry(registry, discovery, || accept_raw_extension_settings)
.expect("inactive fallback configuration is valid")
.connect_websocket_with_cx(&cx, AsyncWsClientTransport::from_upgraded(client_io))
.await
.expect("discovery retains inactive extension negotiation");
let next_id_before = client.next_id;
let error = client
.request_final_extension(&cx, &extension_id, "example/echo", serde_json::json!({}))
.await
.expect_err("inactive extension setting rejects before wire contact");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(
client.next_id, next_id_before,
"no-contact rejection retains request ID state"
);
client
.close(&cx)
.await
.expect("close inactive extension client");
assert!(matches!(peer.join(&cx).await, Ok(())));
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_async_cancelled_connect_performs_no_transport_contact() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let contacts = Arc::new(AtomicUsize::new(0));
let peer_contacts = Arc::clone(&contacts);
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let _ = server.recv(&peer_cx).await;
peer_contacts.fetch_add(1, Ordering::SeqCst);
})
.expect("spawn idle WebSocket peer");
let cancelled = Cx::for_testing();
cancelled.set_cancel_requested(true);
let error = WebSocketClient::connect_with_cx(
&cancelled,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect_err("cancelled caller context must reject before discovery");
assert_eq!(error.code, McpErrorCode::RequestCancelled);
assert_eq!(contacts.load(Ordering::SeqCst), 0);
peer.abort();
let _ = peer.join(&cx).await;
});
}
#[cfg(all(feature = "legacy-2024-11-05", feature = "websocket-experimental"))]
#[test]
fn websocket_async_exact_legacy_completion_is_isolated_and_callable() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let contacts = Arc::new(AtomicUsize::new(0));
let peer_contacts = Arc::clone(&contacts);
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(initialize) = server
.recv(&peer_cx)
.await
.expect("peer receives exact initialize")
else {
panic!("exact legacy must initialize first");
};
peer_contacts.fetch_add(1, Ordering::SeqCst);
assert_eq!(initialize.method, "initialize");
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
initialize.id.expect("initialize has an ID"),
serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"serverInfo": {
"name": "legacy-async-websocket-test",
"version": "1.0"
}
}),
)),
)
.await
.expect("peer sends initialize response");
let JsonRpcMessage::Request(initialized) = server
.recv(&peer_cx)
.await
.expect("peer receives initialized notification")
else {
panic!("exact legacy lifecycle frame must be a request notification");
};
peer_contacts.fetch_add(1, Ordering::SeqCst);
assert_eq!(initialized.method, "notifications/initialized");
assert!(initialized.id.is_none());
let JsonRpcMessage::Request(completion) = server
.recv(&peer_cx)
.await
.expect("peer receives exact legacy completion")
else {
panic!("exact legacy completion must be a request");
};
peer_contacts.fetch_add(1, Ordering::SeqCst);
assert_eq!(completion.method, "completion/complete");
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
completion.id.expect("completion has an ID"),
serde_json::json!({
"completion": {
"values": ["staging"],
"total": 1,
"hasMore": false
}
}),
)),
)
.await
.expect("peer sends exact legacy completion response");
})
.expect("spawn exact legacy WebSocket peer");
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::LegacyOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("exact legacy connection negotiates");
assert_eq!(client.selected_protocol_era(), ProtocolEra::Legacy2024);
let raw_next_id = client.next_id;
let raw_error = client
.request_with_raw_result(
&cx,
"tools/list",
Some(serde_json::json!({
"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28"}
})),
)
.await
.expect_err("exact legacy raw request cannot send a final-only shape");
assert_eq!(raw_error.code, McpErrorCode::InvalidParams);
assert_eq!(
client.next_id, raw_next_id,
"wrong-era raw request must reject before allocating an ID or making contact",
);
let completion = client
.complete(
&cx,
CompletionParams {
reference: CompletionReference::Prompt {
name: "deploy".to_owned(),
},
argument: CompletionArgument {
name: "environment".to_owned(),
value: "sta".to_owned(),
},
context: None,
},
)
.await
.expect("exact legacy completion remains callable after isolation");
assert!(matches!(
completion,
CoreResult::Legacy(LegacyCoreResult::Completion(_))
));
assert!(matches!(peer.join(&cx).await, Ok(())));
assert_eq!(contacts.load(Ordering::SeqCst), 3);
client
.close(&cx)
.await
.expect("close legacy client transport");
});
}
#[cfg(all(feature = "legacy-2024-11-05", feature = "websocket-experimental"))]
#[test]
fn websocket_async_exact_legacy_reverse_handlers_are_caller_cx_owned() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(initialize) = server
.recv(&peer_cx)
.await
.expect("peer receives exact initialize")
else {
panic!("exact legacy must initialize first");
};
let initialize_source = serde_json::to_string(&initialize)
.expect("initialize request serializes");
assert!(initialize_source.contains("\"sampling\":{}"));
assert!(initialize_source.contains("\"roots\":{\"listChanged\":false}"));
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
initialize.id.expect("initialize has an ID"),
serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"serverInfo": {"name": "legacy-reverse", "version": "1.0"}
}),
)),
)
.await
.expect("peer replies to initialize");
let _ = server
.recv(&peer_cx)
.await
.expect("peer receives initialized notification");
let JsonRpcMessage::Request(completion) = server
.recv(&peer_cx)
.await
.expect("peer receives completion request")
else {
panic!("expected completion request");
};
server
.send(
&peer_cx,
&JsonRpcMessage::Request(JsonRpcRequest::new(
"sampling/createMessage",
Some(serde_json::json!({"messages": [], "maxTokens": 3})),
RequestId::Number(41),
)),
)
.await
.expect("peer sends sampling request");
let JsonRpcMessage::Response(sampling) = server
.recv(&peer_cx)
.await
.expect("peer receives sampling response")
else {
panic!("expected sampling response");
};
assert!(sampling.id.as_ref().is_some_and(|id| id.correlates_with(&RequestId::Number(41))));
assert!(sampling.error.is_none());
server
.send(
&peer_cx,
&JsonRpcMessage::Request(JsonRpcRequest::new(
"roots/list",
Some(serde_json::json!({})),
RequestId::Number(42),
)),
)
.await
.expect("peer sends roots request");
let JsonRpcMessage::Response(roots) = server
.recv(&peer_cx)
.await
.expect("peer receives roots response")
else {
panic!("expected roots response");
};
assert!(roots.id.as_ref().is_some_and(|id| id.correlates_with(&RequestId::Number(42))));
assert!(roots.error.is_none());
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
completion.id.expect("completion has an ID"),
serde_json::json!({"completion": {"values": ["staging"], "total": 1, "hasMore": false}}),
)),
)
.await
.expect("peer replies to completion");
})
.expect("spawn exact legacy reverse peer");
let handlers = ReverseRequestHandlers::new()
.with_sampling_create_message(|callback_cx, _cancellation, _params| {
Box::pin(async move {
callback_cx
.checkpoint()
.map_err(|_| McpError::request_cancelled())?;
Ok(CreateMessageResult::text("handled", "test-model"))
})
})
.with_roots_list(|callback_cx, _cancellation, _params| {
Box::pin(async move {
callback_cx
.checkpoint()
.map_err(|_| McpError::request_cancelled())?;
Ok(ListRootsResult::new(Vec::new()))
})
});
let mut client = WebSocketClient::connect_with_reverse_request_handlers_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::LegacyOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
handlers,
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("exact legacy reverse client connects");
let result = client
.complete(
&cx,
CompletionParams {
reference: CompletionReference::Prompt {
name: "deploy".to_owned(),
},
argument: CompletionArgument {
name: "environment".to_owned(),
value: "sta".to_owned(),
},
context: None,
},
)
.await
.expect("reverse callbacks remain within completion ownership");
assert!(matches!(
result,
CoreResult::Legacy(LegacyCoreResult::Completion(_))
));
assert!(matches!(peer.join(&cx).await, Ok(())));
client
.close(&cx)
.await
.expect("close exact legacy reverse client");
});
}
#[cfg(all(feature = "legacy-2024-11-05", feature = "websocket-experimental"))]
#[test]
fn websocket_async_legacy_reverse_callback_matching_cancellation_wakes_and_reuses_reader() {
run_test(|| async {
struct CallbackDrop(Arc<AtomicBool>);
impl Drop for CallbackDrop {
fn drop(&mut self) {
self.0.store(true, Ordering::Release);
}
}
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let callback_started = Arc::new(AtomicBool::new(false));
let peer_callback_started = Arc::clone(&callback_started);
let callback_dropped = Arc::new(AtomicBool::new(false));
let callback_mutated = Arc::new(AtomicBool::new(false));
let callback_cx = Arc::new(Mutex::new(None::<Cx>));
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(initialize) = server
.recv(&peer_cx)
.await
.expect("peer receives exact initialize")
else {
panic!("exact legacy must initialize first");
};
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
initialize.id.expect("initialize has an ID"),
serde_json::json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"serverInfo": {"name": "callback-cancel", "version": "1.0"}
}),
)),
)
.await
.expect("peer replies to initialize");
let _ = server
.recv(&peer_cx)
.await
.expect("peer receives initialized notification");
let JsonRpcMessage::Request(first) = server
.recv(&peer_cx)
.await
.expect("peer receives first completion")
else {
panic!("expected first completion request");
};
server
.send(
&peer_cx,
&JsonRpcMessage::Request(JsonRpcRequest::new(
"sampling/createMessage",
Some(serde_json::json!({"messages": [], "maxTokens": 3})),
RequestId::Number(41),
)),
)
.await
.expect("peer sends parked callback");
for _ in 0..1_024 {
if peer_callback_started.load(Ordering::Acquire) {
break;
}
asupersync::runtime::yield_now().await;
}
assert!(peer_callback_started.load(Ordering::Acquire));
server
.send(
&peer_cx,
&JsonRpcMessage::Request(JsonRpcRequest::notification(
"notifications/cancelled",
Some(serde_json::json!({"requestId": 41})),
)),
)
.await
.expect("peer sends matching callback cancellation");
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
first.id.expect("first completion ID"),
serde_json::json!({"completion": {"values": ["one"], "total": 1, "hasMore": false}}),
)),
)
.await
.expect("peer answers first completion without callback response");
let JsonRpcMessage::Request(second) = server
.recv(&peer_cx)
.await
.expect("peer receives reusable follow-up completion")
else {
panic!("cancelled callback must not emit a late response frame");
};
assert_eq!(second.method, "completion/complete");
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
second.id.expect("follow-up completion ID"),
serde_json::json!({"completion": {"values": ["two"], "total": 1, "hasMore": false}}),
)),
)
.await
.expect("peer answers follow-up completion");
})
.expect("spawn matching-cancellation peer");
let (_park_sender, park_receiver) = oneshot::channel::<()>();
let parked_receiver = Arc::new(Mutex::new(Some(park_receiver)));
let handlers = ReverseRequestHandlers::new().with_sampling_create_message({
let callback_started = Arc::clone(&callback_started);
let callback_dropped = Arc::clone(&callback_dropped);
let callback_mutated = Arc::clone(&callback_mutated);
let callback_cx = Arc::clone(&callback_cx);
let parked_receiver = Arc::clone(&parked_receiver);
move |handler_cx, _cancellation, _params| {
let mut receiver = parked_receiver
.lock()
.expect("callback receiver lock")
.take()
.expect("one callback receiver");
let callback_started = Arc::clone(&callback_started);
let callback_dropped = Arc::clone(&callback_dropped);
let callback_mutated = Arc::clone(&callback_mutated);
let callback_cx = Arc::clone(&callback_cx);
Box::pin(async move {
let _dropped = CallbackDrop(callback_dropped);
callback_started.store(true, Ordering::Release);
*callback_cx.lock().expect("callback Cx lock") = Some(handler_cx.clone());
receiver
.recv(handler_cx)
.await
.map_err(|_| McpError::request_cancelled())?;
handler_cx
.checkpoint()
.map_err(|_| McpError::request_cancelled())?;
callback_mutated.store(true, Ordering::Release);
Ok(CreateMessageResult::text("late", "cancelled"))
})
}
});
let mut client = WebSocketClient::connect_with_reverse_request_handlers_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::LegacyOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
handlers,
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("legacy callback client connects");
let completion = CompletionParams {
reference: CompletionReference::Prompt {
name: "deploy".to_owned(),
},
argument: CompletionArgument {
name: "environment".to_owned(),
value: "sta".to_owned(),
},
context: None,
};
client
.complete(&cx, completion.clone())
.await
.expect("matching cancellation leaves the owner request readable");
for _ in 0..1_024 {
if callback_dropped.load(Ordering::Acquire) {
break;
}
asupersync::runtime::yield_now().await;
}
assert!(callback_dropped.load(Ordering::Acquire));
assert!(
callback_cx
.lock()
.expect("callback Cx lock")
.as_ref()
.is_some_and(|callback_cx| callback_cx.checkpoint().is_err()),
"aborting the retained callback must wake and cancel its task Cx"
);
assert!(
!callback_mutated.load(Ordering::Acquire),
"a cancelled callback cannot mutate after its parked await"
);
client
.complete(&cx, completion)
.await
.expect("cancelled callback leaves the sole reader reusable");
assert!(matches!(peer.join(&cx).await, Ok(())));
client.close(&cx).await.expect("close callback client");
});
}
#[cfg(all(feature = "legacy-2024-11-05", feature = "websocket-experimental"))]
#[test]
fn websocket_async_legacy_reverse_callback_foreign_cancellation_emits_one_response() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let callback_started = Arc::new(AtomicBool::new(false));
let foreign_cancellation_sent = Arc::new(AtomicBool::new(false));
let peer_callback_started = Arc::clone(&callback_started);
let peer_foreign_cancellation_sent = Arc::clone(&foreign_cancellation_sent);
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(initialize) = server.recv(&peer_cx).await.expect("initialize") else { panic!("expected initialize"); };
server.send(&peer_cx, &JsonRpcMessage::Response(JsonRpcResponse::success(initialize.id.expect("initialize ID"), serde_json::json!({"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"foreign-cancel","version":"1.0"}})))).await.expect("initialize response");
let _ = server.recv(&peer_cx).await.expect("initialized");
let JsonRpcMessage::Request(completion) = server.recv(&peer_cx).await.expect("completion") else { panic!("expected completion"); };
server.send(&peer_cx, &JsonRpcMessage::Request(JsonRpcRequest::new("sampling/createMessage", Some(serde_json::json!({"messages":[],"maxTokens":3})), RequestId::Number(41)))).await.expect("callback request");
for _ in 0..1_024 {
if peer_callback_started.load(Ordering::Acquire) {
break;
}
asupersync::runtime::yield_now().await;
}
assert!(peer_callback_started.load(Ordering::Acquire));
server.send(&peer_cx, &JsonRpcMessage::Request(JsonRpcRequest::notification("notifications/cancelled", Some(serde_json::json!({"requestId":42}))))).await.expect("foreign cancellation");
peer_foreign_cancellation_sent.store(true, Ordering::Release);
let JsonRpcMessage::Response(callback) = server.recv(&peer_cx).await.expect("one callback response") else { panic!("foreign cancellation must not suppress callback response"); };
assert!(callback.id.as_ref().is_some_and(|id| id.correlates_with(&RequestId::Number(41))));
server.send(&peer_cx, &JsonRpcMessage::Response(JsonRpcResponse::success(completion.id.expect("completion ID"), serde_json::json!({"completion":{"values":["foreign"],"total":1,"hasMore":false}})))).await.expect("completion response");
})
.expect("spawn foreign-cancellation peer");
let (park_sender, park_receiver) = oneshot::channel::<()>();
let parked_receiver = Arc::new(Mutex::new(Some(park_receiver)));
let handlers = ReverseRequestHandlers::new().with_sampling_create_message({
let callback_started = Arc::clone(&callback_started);
let parked_receiver = Arc::clone(&parked_receiver);
move |handler_cx, cancellation, _params| {
let mut receiver = parked_receiver
.lock()
.expect("callback receiver lock")
.take()
.expect("one callback receiver");
let callback_started = Arc::clone(&callback_started);
Box::pin(async move {
callback_started.store(true, Ordering::Release);
receiver
.recv(handler_cx)
.await
.map_err(|_| McpError::request_cancelled())?;
cancellation.checkpoint()?;
Ok(CreateMessageResult::text("handled", "foreign-cancel"))
})
}
});
let mut release = cx
.spawn({
let callback_started = Arc::clone(&callback_started);
let foreign_cancellation_sent = Arc::clone(&foreign_cancellation_sent);
move |release_cx| async move {
for _ in 0..1_024 {
if callback_started.load(Ordering::Acquire)
&& foreign_cancellation_sent.load(Ordering::Acquire)
{
break;
}
asupersync::runtime::yield_now().await;
}
assert!(callback_started.load(Ordering::Acquire));
assert!(foreign_cancellation_sent.load(Ordering::Acquire));
park_sender.send(&release_cx, ()).expect("release callback");
}
})
.expect("spawn callback releaser");
let mut client = WebSocketClient::connect_with_reverse_request_handlers_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::LegacyOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
handlers,
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("legacy callback client connects");
client
.complete(
&cx,
CompletionParams {
reference: CompletionReference::Prompt {
name: "deploy".to_owned(),
},
argument: CompletionArgument {
name: "environment".to_owned(),
value: "sta".to_owned(),
},
context: None,
},
)
.await
.expect("foreign cancellation is inert to the live callback and owner request");
assert!(matches!(release.join(&cx).await, Ok(())));
assert!(matches!(peer.join(&cx).await, Ok(())));
client
.close(&cx)
.await
.expect("close foreign-cancellation client");
});
}
#[cfg(all(feature = "legacy-2024-11-05", feature = "websocket-experimental"))]
#[test]
fn websocket_async_legacy_reverse_callback_malformed_cancellation_is_inert() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let callback_started = Arc::new(AtomicBool::new(false));
let malformed_sent = Arc::new(AtomicBool::new(false));
let peer_callback_started = Arc::clone(&callback_started);
let peer_malformed_sent = Arc::clone(&malformed_sent);
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(initialize) = server
.recv(&peer_cx)
.await
.expect("initialize")
else {
panic!("expected initialize");
};
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
initialize.id.expect("initialize ID"),
serde_json::json!({"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"malformed-cancel","version":"1.0"}}),
)),
)
.await
.expect("initialize response");
let _ = server.recv(&peer_cx).await.expect("initialized");
let JsonRpcMessage::Request(first) = server
.recv(&peer_cx)
.await
.expect("first completion")
else {
panic!("expected first completion");
};
server
.send(
&peer_cx,
&JsonRpcMessage::Request(JsonRpcRequest::new(
"sampling/createMessage",
Some(serde_json::json!({"messages":[],"maxTokens":3})),
RequestId::Number(41),
)),
)
.await
.expect("callback request");
for _ in 0..1_024 {
if peer_callback_started.load(Ordering::Acquire) {
break;
}
asupersync::runtime::yield_now().await;
}
assert!(peer_callback_started.load(Ordering::Acquire));
// `requestId: null` is the same control envelope as the
// positive case, but fails exact legacy cancellation
// parameter admission and must be inert.
server
.send(
&peer_cx,
&JsonRpcMessage::Request(JsonRpcRequest::notification(
"notifications/cancelled",
Some(serde_json::json!({"requestId":null})),
)),
)
.await
.expect("malformed cancellation");
peer_malformed_sent.store(true, Ordering::Release);
let JsonRpcMessage::Response(callback) = server
.recv(&peer_cx)
.await
.expect("one callback response")
else {
panic!("malformed cancellation must not emit a control response");
};
assert!(callback.id.as_ref().is_some_and(|id| {
id.correlates_with(&RequestId::Number(41))
}));
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
first.id.expect("first completion ID"),
serde_json::json!({"completion":{"values":["malformed"],"total":1,"hasMore":false}}),
)),
)
.await
.expect("first completion response");
let JsonRpcMessage::Request(second) = server
.recv(&peer_cx)
.await
.expect("follow-up completion")
else {
panic!("malformed cancellation must leave the client reusable");
};
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
second.id.expect("follow-up completion ID"),
serde_json::json!({"completion":{"values":["reuse"],"total":1,"hasMore":false}}),
)),
)
.await
.expect("follow-up completion response");
})
.expect("spawn malformed-cancellation peer");
let (release_sender, release_receiver) = oneshot::channel::<()>();
let receiver = Arc::new(Mutex::new(Some(release_receiver)));
let handlers = ReverseRequestHandlers::new().with_sampling_create_message({
let callback_started = Arc::clone(&callback_started);
let receiver = Arc::clone(&receiver);
move |handler_cx, cancellation, _params| {
let mut receiver = receiver
.lock()
.expect("callback receiver lock")
.take()
.expect("one callback receiver");
let callback_started = Arc::clone(&callback_started);
Box::pin(async move {
callback_started.store(true, Ordering::Release);
receiver
.recv(handler_cx)
.await
.map_err(|_| McpError::request_cancelled())?;
cancellation.checkpoint()?;
Ok(CreateMessageResult::text("handled", "malformed-cancel"))
})
}
});
let mut release = cx
.spawn({
let callback_started = Arc::clone(&callback_started);
let malformed_sent = Arc::clone(&malformed_sent);
move |release_cx| async move {
for _ in 0..1_024 {
if callback_started.load(Ordering::Acquire)
&& malformed_sent.load(Ordering::Acquire)
{
break;
}
asupersync::runtime::yield_now().await;
}
assert!(callback_started.load(Ordering::Acquire));
assert!(malformed_sent.load(Ordering::Acquire));
release_sender
.send(&release_cx, ())
.expect("release callback after malformed control");
}
})
.expect("spawn malformed callback releaser");
let mut client = WebSocketClient::connect_with_reverse_request_handlers_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::LegacyOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
handlers,
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("legacy callback client connects");
let completion = CompletionParams {
reference: CompletionReference::Prompt {
name: "deploy".to_owned(),
},
argument: CompletionArgument {
name: "environment".to_owned(),
value: "sta".to_owned(),
},
context: None,
};
client
.complete(&cx, completion.clone())
.await
.expect("malformed cancellation leaves callback and owner live");
client
.complete(&cx, completion)
.await
.expect("malformed cancellation leaves reader reusable");
assert!(matches!(release.join(&cx).await, Ok(())));
assert!(matches!(peer.join(&cx).await, Ok(())));
client
.close(&cx)
.await
.expect("close malformed-cancellation client");
});
}
#[cfg(all(feature = "legacy-2024-11-05", feature = "websocket-experimental"))]
#[test]
fn websocket_async_legacy_reverse_callback_panic_is_terminal() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let callback_started = Arc::new(AtomicBool::new(false));
let peer_callback_started = Arc::clone(&callback_started);
let panic_terminal_observed = Arc::new(AtomicBool::new(false));
let peer_panic_terminal_observed = Arc::clone(&panic_terminal_observed);
let mut peer = cx.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(initialize) = server.recv(&peer_cx).await.expect("initialize") else { panic!("expected initialize"); };
server.send(&peer_cx, &JsonRpcMessage::Response(JsonRpcResponse::success(initialize.id.expect("initialize ID"), serde_json::json!({"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"callback-panic","version":"1.0"}})))).await.expect("initialize response");
let _ = server.recv(&peer_cx).await.expect("initialized");
let JsonRpcMessage::Request(completion) = server.recv(&peer_cx).await.expect("completion") else { panic!("expected completion"); };
server.send(&peer_cx, &JsonRpcMessage::Request(JsonRpcRequest::new("sampling/createMessage", Some(serde_json::json!({"messages":[],"maxTokens":3})), RequestId::Number(41)))).await.expect("callback request");
for _ in 0..1_024 {
if peer_callback_started.load(Ordering::Acquire) {
break;
}
asupersync::runtime::yield_now().await;
}
assert!(peer_callback_started.load(Ordering::Acquire));
let _ = completion;
for _ in 0..1_024 {
if peer_panic_terminal_observed.load(Ordering::Acquire) {
break;
}
asupersync::runtime::yield_now().await;
}
assert!(
peer_panic_terminal_observed.load(Ordering::Acquire),
"the client must surface callback panic without another peer frame"
);
}).expect("spawn panic peer");
let handlers = ReverseRequestHandlers::new().with_sampling_create_message({
let callback_started = Arc::clone(&callback_started);
move |_handler_cx, _cancellation, _params| {
let callback_started = Arc::clone(&callback_started);
Box::pin(async move {
callback_started.store(true, Ordering::Release);
panic!("websocket reverse callback panic canary");
#[allow(unreachable_code)]
Ok(CreateMessageResult::text("unreachable", "panic"))
})
}
});
let mut client = WebSocketClient::connect_with_reverse_request_handlers_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::LegacyOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
handlers,
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("legacy callback client connects");
let completion = CompletionParams {
reference: CompletionReference::Prompt {
name: "deploy".to_owned(),
},
argument: CompletionArgument {
name: "environment".to_owned(),
value: "sta".to_owned(),
},
context: None,
};
let error = client
.complete(&cx, completion.clone())
.await
.expect_err("callback panic is connection-terminal");
assert_eq!(error.message, "WebSocket reverse callback task panicked");
panic_terminal_observed.store(true, Ordering::Release);
let after = client
.complete(&cx, completion)
.await
.expect_err("terminal callback panic blocks reuse");
assert_eq!(after.message, "WebSocket client is closed");
assert!(matches!(peer.join(&cx).await, Ok(())));
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_async_modern_rejects_exact_legacy_reverse_rpc() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(discover) = server
.recv(&peer_cx)
.await
.expect("peer receives discovery")
else {
panic!("expected discovery request");
};
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
discover.id.expect("discovery ID"),
async_modern_discovery_result(),
)),
)
.await
.expect("peer sends discovery result");
let _ = server
.recv(&peer_cx)
.await
.expect("peer receives modern request");
server
.send(
&peer_cx,
&JsonRpcMessage::Request(JsonRpcRequest::new(
"sampling/createMessage",
Some(serde_json::json!({"messages": [], "maxTokens": 1})),
RequestId::Number(99),
)),
)
.await
.expect("peer sends forbidden legacy reverse request");
assert!(matches!(
server.recv(&peer_cx).await,
Err(TransportError::Closed)
));
})
.expect("spawn modern reverse rejection peer");
let mut client = WebSocketClient::connect_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("modern client connects");
let error = client
.request_with_raw_result(
&cx,
"tools/list",
Some(
client
.with_modern_request_metadata(serde_json::json!({}))
.expect("metadata"),
),
)
.await
.expect_err("modern rejects exact legacy reverse request");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(client.closed);
assert!(matches!(peer.join(&cx).await, Ok(())));
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_modern_sampling_reverse_request_is_answered_by_the_typed_handler() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(discover) = server
.recv(&peer_cx)
.await
.expect("peer receives discovery")
else {
panic!("expected discovery request");
};
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
discover.id.expect("discovery ID"),
async_modern_discovery_result(),
)),
)
.await
.expect("peer sends discovery result");
let _ = server
.recv(&peer_cx)
.await
.expect("peer receives modern tools/list");
server
.send(
&peer_cx,
&JsonRpcMessage::Request(JsonRpcRequest::new(
"sampling/createMessage",
Some(serde_json::json!({
"_meta": {
"io.modelcontextprotocol/protocolVersion": MODERN_PROTOCOL_VERSION,
"io.modelcontextprotocol/clientCapabilities": {}
},
"messages": [{
"role": "user",
"content": {"type": "text", "text": "hello"}
}],
"maxTokens": 8
})),
RequestId::Number(99),
)),
)
.await
.expect("peer sends modern sampling reverse request");
let JsonRpcMessage::Response(sampling) = server
.recv(&peer_cx)
.await
.expect("peer receives modern sampling response")
else {
panic!("expected sampling response");
};
assert!(
sampling
.id
.as_ref()
.is_some_and(|id| id.correlates_with(&RequestId::Number(99)))
);
assert!(sampling.error.is_none());
assert_eq!(
sampling.result.as_ref().and_then(|value| value.get("model")),
Some(&serde_json::json!("modern-ws-model"))
);
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
RequestId::Number(2),
async_modern_tools_list_result(),
)),
)
.await
.expect("peer completes tools/list after sampling");
let _ = server.recv(&peer_cx).await;
})
.expect("spawn modern sampling reverse peer");
let handlers = ReverseRequestHandlers::new().with_modern_sampling_create_message(
|_cx, _cancellation, params| {
Box::pin(async move {
assert_eq!(params.max_tokens.to_string(), "8");
Ok(FinalCreateMessageResult {
content: fastmcp_protocol::FinalSamplingMessageContent::Block(
fastmcp_protocol::common_types::SamplingContentBlock::Text {
text: "sampled".to_owned(),
annotations: None,
meta: None,
additional: BTreeMap::new(),
},
),
model: "modern-ws-model".to_owned(),
role: fastmcp_protocol::Role::Assistant,
stop_reason: None,
meta: None,
})
})
},
);
let mut client = WebSocketClient::connect_with_reverse_request_handlers_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
handlers,
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("modern client connects with sampling handler");
let listed = client
.list_tools(&cx, None)
.await
.expect("tools/list completes after modern sampling is answered");
assert!(matches!(
listed,
CoreResult::Final(FinalCoreResult::ToolsList { .. })
));
client
.close(&cx)
.await
.expect("close modern sampling reverse client");
assert!(matches!(peer.join(&cx).await, Ok(())));
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_modern_elicitation_reverse_request_is_answered_by_the_typed_handler() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(discover) = server
.recv(&peer_cx)
.await
.expect("peer receives discovery")
else {
panic!("expected discovery request");
};
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
discover.id.expect("discovery ID"),
async_modern_discovery_result(),
)),
)
.await
.expect("peer sends discovery result");
let _ = server
.recv(&peer_cx)
.await
.expect("peer receives modern tools/list");
server
.send(
&peer_cx,
&JsonRpcMessage::Request(JsonRpcRequest::new(
"elicitation/create",
Some(serde_json::json!({
"mode": "form",
"message": "Choose a name",
"requestedSchema": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"]
}
})),
RequestId::Number(77),
)),
)
.await
.expect("peer sends modern elicitation reverse request");
let JsonRpcMessage::Response(elicited) = server
.recv(&peer_cx)
.await
.expect("peer receives modern elicitation response")
else {
panic!("expected elicitation response");
};
assert!(
elicited
.id
.as_ref()
.is_some_and(|id| id.correlates_with(&RequestId::Number(77)))
);
assert!(elicited.error.is_none());
assert_eq!(
elicited
.result
.as_ref()
.and_then(|value| value.get("action")),
Some(&serde_json::json!("accept"))
);
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
RequestId::Number(2),
async_modern_tools_list_result(),
)),
)
.await
.expect("peer completes tools/list after elicitation");
let _ = server.recv(&peer_cx).await;
})
.expect("spawn modern elicitation reverse peer");
let handlers = ReverseRequestHandlers::new().with_modern_elicitation_create(
|_cx, _cancellation, params| {
Box::pin(async move {
assert_eq!(params.message(), "Choose a name");
let mut content = std::collections::HashMap::new();
content.insert(
"name".to_owned(),
fastmcp_protocol::ElicitContentValue::String("Ada".to_owned()),
);
Ok(ElicitResult::accept(content))
})
},
);
let mut client = WebSocketClient::connect_with_reverse_request_handlers_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
handlers,
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("modern client connects with elicitation handler");
let listed = client
.list_tools(&cx, None)
.await
.expect("tools/list completes after modern elicitation is answered");
assert!(matches!(
listed,
CoreResult::Final(FinalCoreResult::ToolsList { .. })
));
client
.close(&cx)
.await
.expect("close modern elicitation reverse client");
assert!(matches!(peer.join(&cx).await, Ok(())));
});
}
#[cfg(feature = "websocket-experimental")]
#[test]
fn websocket_modern_roots_reverse_request_is_answered_by_the_typed_handler() {
run_test(|| async {
let cx = Cx::current().expect("test runtime installs caller context");
let (client_io, server_io) = async_websocket_pair();
let mut peer = cx
.spawn(move |peer_cx| async move {
let mut server = AsyncWsServerTransport::from_upgraded(server_io);
let JsonRpcMessage::Request(discover) = server
.recv(&peer_cx)
.await
.expect("peer receives discovery")
else {
panic!("expected discovery request");
};
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
discover.id.expect("discovery ID"),
async_modern_discovery_result(),
)),
)
.await
.expect("peer sends discovery result");
let _ = server
.recv(&peer_cx)
.await
.expect("peer receives modern tools/list");
server
.send(
&peer_cx,
&JsonRpcMessage::Request(JsonRpcRequest::new(
"roots/list",
Some(serde_json::json!({})),
RequestId::Number(55),
)),
)
.await
.expect("peer sends modern roots reverse request");
let JsonRpcMessage::Response(roots) = server
.recv(&peer_cx)
.await
.expect("peer receives modern roots response")
else {
panic!("expected roots response");
};
assert!(
roots
.id
.as_ref()
.is_some_and(|id| id.correlates_with(&RequestId::Number(55)))
);
assert!(roots.error.is_none());
assert_eq!(
roots.result.as_ref().and_then(|value| value.get("roots")),
Some(&serde_json::json!([{"uri":"file:///workspace"}]))
);
server
.send(
&peer_cx,
&JsonRpcMessage::Response(JsonRpcResponse::success(
RequestId::Number(2),
async_modern_tools_list_result(),
)),
)
.await
.expect("peer completes tools/list after roots/list");
let _ = server.recv(&peer_cx).await;
})
.expect("spawn modern roots reverse peer");
let handlers = ReverseRequestHandlers::new().with_modern_roots_list(
|_cx, _cancellation, _params| {
Box::pin(async move {
Ok(FinalEmbeddedRootsListResult {
roots: vec![fastmcp_protocol::Root::new("file:///workspace")],
})
})
},
);
let mut client = WebSocketClient::connect_with_reverse_request_handlers_with_cx(
&cx,
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
async_websocket_client_info(),
ClientCapabilities::default(),
handlers,
AsyncWsClientTransport::from_upgraded(client_io),
)
.await
.expect("modern client connects with roots handler");
let listed = client
.list_tools(&cx, None)
.await
.expect("tools/list completes after modern roots/list is answered");
assert!(matches!(
listed,
CoreResult::Final(FinalCoreResult::ToolsList { .. })
));
client
.close(&cx)
.await
.expect("close modern roots reverse client");
assert!(matches!(peer.join(&cx).await, Ok(())));
});
}
#[test]
fn websocket_modern_connection_multiplexes_progress_cancellation_and_exact_result_source() {
let sent = Arc::new(Mutex::new(Vec::new()));
let closed = Arc::new(AtomicBool::new(false));
let receiver = WebSocketTestRecv {
frames: VecDeque::from([
websocket_test_modern_discovery_frame(1),
websocket_test_frame(
r#"{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"ws-progress","progress":1.20e+4}}"#,
),
websocket_test_frame(
r#"{"jsonrpc":"2.0","id":4,"result":{"opaque":{"decimal":1.20e+4}}}"#,
),
websocket_test_frame(r#"{"jsonrpc":"2.0","id":3,"result":{"first":true}}"#),
]),
};
let sender = WebSocketTestSend {
sent: Arc::clone(&sent),
closed: Arc::clone(&closed),
};
let client = SynchronousWebSocketClient::connect_with_cx(
Cx::for_testing(),
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
ClientInfo {
name: "websocket-client".to_owned(),
version: "1.0".to_owned(),
},
ClientCapabilities::default(),
receiver,
sender,
)
.expect("modern discovery negotiates one WebSocket era");
assert_eq!(client.selected_protocol_era(), ProtocolEra::Modern2026);
let mut first = client
.execute(
"tools/call",
Some(serde_json::json!({
"name": "first",
"arguments": {},
"_meta": {"progressToken": "ws-progress"}
})),
)
.expect("first request is committed");
let mut second = client
.execute(
"tools/call",
Some(serde_json::json!({"name": "second", "arguments": {}})),
)
.expect("second request is committed before the first resolves");
client.drive().expect("progress routes to the first owner");
assert_eq!(
first
.take_stream_notifications()
.expect("progress remains request-owned")
.len(),
1
);
client
.drive()
.expect("reordered second response routes by exact ID");
client.drive().expect("first response routes by exact ID");
let (_, second_raw) = client
.try_take_response_with_raw_result(&mut second)
.expect("second response is available")
.expect("second execution completed");
assert_eq!(
second_raw.as_deref(),
Some(r#"{"opaque":{"decimal":1.20e+4}}"#),
"the exact peer result lexeme is retained rather than reserialized"
);
assert!(
client
.try_take_response_with_raw_result(&mut first)
.expect("first response is available")
.is_some()
);
let mut cancelled = client
.execute(
"tools/call",
Some(serde_json::json!({"name": "cancel", "arguments": {}})),
)
.expect("third request is committed");
client
.cancel(&mut cancelled)
.expect("selected-era cancellation sends");
client.close().expect("shutdown closes the owned halves");
assert!(closed.load(Ordering::Acquire));
let sent = sent.lock().expect("test send log is available");
assert!(matches!(
sent.first(),
Some(JsonRpcMessage::Request(request)) if request.method == SERVER_DISCOVER_METHOD
));
assert!(sent.iter().any(|message| matches!(
message,
JsonRpcMessage::Request(request) if request.method == "notifications/cancelled"
)));
}
#[test]
fn websocket_modern_only_refusal_never_replays_initialize_on_the_same_connection() {
let sent = Arc::new(Mutex::new(Vec::new()));
let receiver = WebSocketTestRecv {
frames: VecDeque::from([websocket_test_frame(
r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}"#,
)]),
};
let error = SynchronousWebSocketClient::connect_with_cx(
Cx::for_testing(),
ClientProtocolPlan::websocket(ProtocolPolicy::ModernOnly),
ClientInfo {
name: "websocket-client".to_owned(),
version: "1.0".to_owned(),
},
ClientCapabilities::default(),
receiver,
WebSocketTestSend {
sent: Arc::clone(&sent),
closed: Arc::new(AtomicBool::new(false)),
},
)
.expect_err("changing only the selected era to modern-only rejects legacy refusal");
assert!(error.to_string().contains("handshake method"));
let sent = sent.lock().expect("test send log is available");
assert_eq!(
sent.len(),
1,
"wrong-era refusal performs no legacy contact"
);
assert!(matches!(
&sent[0],
JsonRpcMessage::Request(request) if request.method == SERVER_DISCOVER_METHOD
));
}
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn websocket_legacy_reverse_request_is_capability_bound_until_its_response() {
let sent = Arc::new(Mutex::new(Vec::new()));
let receiver = WebSocketTestRecv {
frames: VecDeque::from([
websocket_test_frame(
r#"{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"legacy-websocket","version":"1.0"}}}"#,
),
websocket_test_frame(
r#"{"jsonrpc":"2.0","id":99,"method":"roots/list","params":{}}"#,
),
]),
};
let client = SynchronousWebSocketClient::connect_with_cx(
Cx::for_testing(),
ClientProtocolPlan::websocket(ProtocolPolicy::LegacyOnly),
ClientInfo {
name: "websocket-client".to_owned(),
version: "1.0".to_owned(),
},
ClientCapabilities::default(),
receiver,
WebSocketTestSend {
sent: Arc::clone(&sent),
closed: Arc::new(AtomicBool::new(false)),
},
)
.expect("exact legacy initialize negotiates the WebSocket connection");
assert!(sent.lock().expect("test send log is available").iter().any(
|message| matches!(
message,
JsonRpcMessage::Request(request) if request.method == "notifications/initialized"
)
));
client.drive().expect("legacy reverse request is admitted");
let reverse = client.take_reverse_requests();
assert_eq!(reverse.len(), 1);
client
.respond_to_reverse_request(&reverse[0], serde_json::json!({"roots": []}))
.expect("only the live reverse capability can answer the peer request");
let sent = sent.lock().expect("test send log is available");
assert!(matches!(
sent.last(),
Some(JsonRpcMessage::Response(response)) if response.id == Some(RequestId::Number(99))
));
}
#[cfg(unix)]
use std::net::{TcpListener, TcpStream};
use std::process::{Command, Stdio};
#[cfg(unix)]
use asupersync::runtime::RuntimeBuilder;
#[test]
fn auto_stdio_fallback_signals_are_explicit_and_bounded() {
assert_eq!(
AutoStdioFallbackSignal::CorrelatedDiscoverMethodNotFound,
AutoStdioFallbackSignal::CorrelatedDiscoverMethodNotFound
);
assert_eq!(
AutoStdioFallbackSignal::CleanFirstProbeTimeout {
source: RequestTimeoutSource::Idle
},
AutoStdioFallbackSignal::CleanFirstProbeTimeout {
source: RequestTimeoutSource::Idle
}
);
assert_ne!(
AutoStdioFallbackSignal::CleanFirstProbeTimeout {
source: RequestTimeoutSource::Idle
},
AutoStdioFallbackSignal::CleanFirstProbeTimeout {
source: RequestTimeoutSource::Absolute
}
);
}
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn legacy_http_public_surface_is_available_in_the_legacy_profile() {
let _: fn(&mut ClientHttpConnection) -> Option<JsonRpcRequest> =
ClientHttpConnection::take_legacy_notification;
let _: fn(&mut HttpClient) -> Option<JsonRpcRequest> = HttpClient::take_legacy_notification;
let _: fn(&mut HttpClient) -> Vec<ServerNotification> =
HttpClient::take_final_server_notifications;
let _: fn(&mut HttpClient) -> Vec<FinalProgressNotificationParams> =
HttpClient::take_final_progress_notifications;
let _ = ModernHttpConnectOutcome::into_legacy_sse;
let _ = LegacySseHttpClient::connect;
let _ = LegacyHttpRequest::wait;
let _ = LegacyHttpRequestCommit::request_id;
}
#[cfg(not(feature = "legacy-2024-11-05"))]
#[test]
fn feature_off_stdio_defaults_modern_and_rejects_legacy_without_contact() {
assert_eq!(DEFAULT_STDIO_PROTOCOL_POLICY, ProtocolPolicy::ModernOnly);
for policy in [ProtocolPolicy::Auto, ProtocolPolicy::LegacyOnly] {
let error = Client::stdio_with_protocol_plan_with_cx(
"fastmcp-client-feature-off-must-not-spawn",
&[],
ClientProtocolPlan::stdio(policy),
Cx::for_testing(),
)
.expect_err("feature-off legacy policies must fail before command resolution");
assert_eq!(error.code, McpErrorCode::InvalidParams);
}
}
#[test]
fn auto_legacy_fallback_admits_an_open_caller_context() {
let cx = Cx::for_testing();
admit_auto_legacy_fallback(&cx)
.expect("an open caller context may authorize the one fresh legacy child");
}
#[test]
fn auto_legacy_fallback_rejects_only_a_cancelled_caller_context() {
let cx = Cx::for_testing();
cx.cancel_fast(asupersync::CancelKind::User);
let error = admit_auto_legacy_fallback(&cx)
.expect_err("the same Auto fallback must not create a legacy child after cancellation");
assert_eq!(error.code, McpErrorCode::RequestCancelled);
}
#[test]
fn negotiated_client_io_preserves_memory_source_from_one_admission() {
use fastmcp_transport::memory::create_memory_transport_pair;
let (client, server) = create_memory_transport_pair();
let (client_recv, client_send) = client.into_split();
let (mut server_recv, mut server_send) = server.into_split();
let mut io = NegotiatedClientIo::new(client_recv, client_send);
let cx = Cx::for_testing();
let response = JsonRpcResponse::success(
RequestId::Number(61),
serde_json::json!({
"resultType": "complete",
"opaque": {"origin": "memory"}
}),
);
server_send
.send(&cx, &JsonRpcMessage::Response(response.clone()))
.expect("peer commits one response");
let received = io
.recv(&cx)
.expect("transport-neutral ingress retains the committed source");
let JsonRpcMessage::Response(typed) = received.message().clone() else {
panic!("memory peer sent one response");
};
let raw_result = raw_result_from_admitted_response(&typed, received, "memory")
.expect("one admitted source derives its typed response and raw result");
assert_eq!(typed, response);
let expected_raw_result = serde_json::to_string(
response
.result
.as_ref()
.expect("success response retains its result"),
)
.expect("committed result serializes");
assert_eq!(raw_result.as_deref(), Some(expected_raw_result.as_str()));
io.send(
&cx,
&JsonRpcMessage::Request(JsonRpcRequest::new("after/admission", None, 62_i64)),
)
.expect("admitted source retention does not mutate the selected transport");
let JsonRpcMessage::Request(request) = server_recv
.recv(&cx)
.expect("peer receives the only outbound request")
else {
panic!("the transport-neutral sender preserves request direction");
};
assert_eq!(request.method, "after/admission");
}
#[test]
fn reverse_callback_forced_cancellation_before_writer_election_wins() {
let state = Arc::new(ReverseCallbackState::default());
let request_id = RequestId::Number(41);
let cancellation = state
.admit(&request_id)
.expect("test callback is admitted before its worker starts");
let writer_turn = Arc::new(Mutex::new(()));
let writer_hold = writer_turn
.lock()
.expect("test owns the writer before the callback can commit");
let (waiting_sender, waiting_receiver) = std::sync::mpsc::sync_channel(1);
let (result_sender, result_receiver) = std::sync::mpsc::sync_channel(1);
let writes = Arc::new(AtomicUsize::new(0));
let worker_state = Arc::clone(&state);
let worker_cancellation = cancellation.clone();
let worker_turn = Arc::clone(&writer_turn);
let worker_request_id = request_id.clone();
let worker_writes = Arc::clone(&writes);
let worker = std::thread::spawn(move || {
waiting_sender
.send(())
.expect("callback worker reaches the writer election");
let _writer = worker_turn
.lock()
.expect("writer election lock remains valid");
let claimed =
worker_state.claim_response_if_open(&worker_request_id, &worker_cancellation);
if claimed {
worker_writes.fetch_add(1, Ordering::AcqRel);
}
result_sender
.send(claimed)
.expect("callback worker reports its terminal election");
});
waiting_receiver
.recv()
.expect("reader processes the queued cancellation before writer ownership releases");
assert!(state.cancel(&request_id));
drop(writer_hold);
assert!(
!result_receiver
.recv()
.expect("callback worker reports the forced election"),
"a cancellation observed while the response waits for writer ownership must win"
);
assert!(cancellation.is_cancel_requested());
assert_eq!(
writes.load(Ordering::Acquire),
0,
"a cancelled callback cannot enter its response write"
);
worker.join().expect("callback worker does not panic");
}
#[test]
fn reverse_callback_forced_writer_election_excludes_late_cancellation() {
let state = Arc::new(ReverseCallbackState::default());
let request_id = RequestId::Number(41);
let cancellation = state
.admit(&request_id)
.expect("test callback is admitted before its worker starts");
let (write_started_sender, write_started_receiver) = std::sync::mpsc::sync_channel(1);
let (release_write_sender, release_write_receiver) = std::sync::mpsc::sync_channel(1);
let worker_state = Arc::clone(&state);
let worker_request_id = request_id.clone();
let worker_cancellation = cancellation.clone();
let worker = std::thread::spawn(move || {
let claimed =
worker_state.claim_response_if_open(&worker_request_id, &worker_cancellation);
// The response is linearly elected before the potentially blocked
// protocol-sized write permits cancellation processing to race it.
write_started_sender
.send(())
.expect("test observes the committed response claim");
release_write_receiver
.recv()
.expect("test releases the protocol-sized write model");
claimed
});
write_started_receiver
.recv()
.expect("test observes the response write election");
let cancel_state = Arc::clone(&state);
let cancel_request_id = request_id.clone();
let cancellation_attempt =
std::thread::spawn(move || cancel_state.cancel(&cancel_request_id));
release_write_sender
.send(())
.expect("test completes the atomic write model");
assert!(
worker.join().expect("callback worker does not panic"),
"the response that reaches the actual write first owns the terminal outcome"
);
assert!(
!cancellation_attempt
.join()
.expect("cancellation observer does not panic"),
"a later cancellation finds no live callback after the write"
);
assert!(!cancellation.is_cancel_requested());
}
#[test]
fn unrecoverable_reverse_callback_write_failure_is_connection_terminal() {
let state = ReverseCallbackState::default();
let request_id = RequestId::Number(41);
let cancellation = state
.admit(&request_id)
.expect("callback is live before its write fails");
let failure =
McpError::internal_error("reverse callback write backpressure is unrecoverable");
state.fail_connection(failure.clone());
let terminal = state
.terminal_error()
.expect("terminal callback failure is retained");
assert_eq!(terminal.code, failure.code);
assert_eq!(terminal.message, failure.message);
assert_eq!(terminal.data, failure.data);
assert!(
cancellation.is_cancel_requested(),
"a terminal write failure cancels every outstanding callback"
);
assert!(
state.admit(&RequestId::Number(42)).is_err(),
"a terminal write failure rejects all later callback admission"
);
}
#[test]
fn reverse_callback_admission_has_a_fixed_backpressure_bound() {
let state = ReverseCallbackState::default();
let mut admitted = Vec::new();
for id in 0..MAX_QUEUED_REVERSE_CALLBACKS {
admitted.push(
state
.admit(&RequestId::Number(
i64::try_from(id).expect("small test ID"),
))
.expect("admission remains available through the exact bound"),
);
}
let error = state
.admit(&RequestId::Number(
i64::try_from(MAX_QUEUED_REVERSE_CALLBACKS).expect("small overflow test ID"),
))
.expect_err("one callback beyond the bound is rejected without queue growth");
assert_eq!(error.code, McpErrorCode::InternalError);
assert_eq!(error.message, "Client reverse callback capacity exceeded");
state.cancel_all();
assert!(
admitted
.iter()
.all(ReverseRequestCancellation::is_cancel_requested),
"shutdown cancellation reaches every bounded admission"
);
}
#[cfg(unix)]
fn http_test_runtime_block_on<F: std::future::Future>(future: F) -> F::Output {
RuntimeBuilder::current_thread()
.build()
.expect("HTTP cache test runtime must build")
.block_on(future)
}
#[cfg(unix)]
fn read_http_cache_test_request(stream: &mut TcpStream) -> serde_json::Value {
let mut wire = Vec::new();
let mut buffer = [0_u8; 4_096];
let head_end = loop {
let read = stream
.read(&mut buffer)
.expect("read HTTP cache test request");
assert!(read > 0, "client closed before a complete HTTP request");
wire.extend_from_slice(&buffer[..read]);
if let Some(position) = wire.windows(4).position(|window| window == b"\r\n\r\n") {
break position + 4;
}
};
let head = std::str::from_utf8(&wire[..head_end]).expect("HTTP request head is UTF-8");
let content_length = head
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length").then(|| {
value
.trim()
.parse::<usize>()
.expect("content length is numeric")
})
})
.expect("HTTP cache request has a content length");
while wire.len() < head_end + content_length {
let read = stream
.read(&mut buffer)
.expect("read HTTP cache request body");
assert!(read > 0, "client closed before the complete HTTP body");
wire.extend_from_slice(&buffer[..read]);
}
serde_json::from_slice(&wire[head_end..head_end + content_length])
.expect("HTTP cache request is JSON-RPC")
}
#[cfg(unix)]
fn write_http_cache_test_response(stream: &mut TcpStream, content_type: &str, body: &[u8]) {
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.expect("write HTTP cache response head");
stream
.write_all(body)
.expect("write HTTP cache response body");
stream.flush().expect("flush HTTP cache response");
}
#[cfg(unix)]
fn http_cache_test_plan(modern_target: &str) -> ClientProtocolPlan {
ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(
CanonicalHttpUrl::parse(modern_target)
.expect("local HTTP cache target is canonical"),
),
None,
None,
"http-cache-test-credential".to_owned(),
"http-cache-test-security".to_owned(),
"http-cache-test-transport".to_owned(),
1,
1,
0,
)
.expect("modern-only HTTP cache plan is complete")
}
#[cfg(all(unix, feature = "legacy-2024-11-05"))]
fn legacy_raw_extension_http_plan(
sse_target: &str,
message_target: &str,
) -> ClientProtocolPlan {
ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(
CanonicalHttpUrl::parse(sse_target)
.expect("local exact legacy SSE target is canonical"),
),
Some(
CanonicalHttpUrl::parse(message_target)
.expect("local exact legacy message target is canonical"),
),
"legacy-raw-extension-credential".to_owned(),
"legacy-raw-extension-security".to_owned(),
"legacy-raw-extension-transport".to_owned(),
1,
1,
1,
)
.expect("exact legacy raw extension HTTP plan is complete")
}
#[cfg(all(unix, feature = "legacy-2024-11-05"))]
fn legacy_completion_http_plan(sse_target: &str, message_target: &str) -> ClientProtocolPlan {
ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(
CanonicalHttpUrl::parse(sse_target)
.expect("local exact legacy completion SSE target is canonical"),
),
Some(
CanonicalHttpUrl::parse(message_target)
.expect("local exact legacy completion message target is canonical"),
),
"legacy-completion-credential".to_owned(),
"legacy-completion-security".to_owned(),
"legacy-completion-transport".to_owned(),
1,
1,
1,
)
.expect("exact legacy completion HTTP plan is complete")
}
#[cfg(all(unix, feature = "legacy-2024-11-05"))]
fn write_legacy_http_accepted_response(stream: &mut TcpStream) {
stream
.write_all(b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
.expect("write exact legacy HTTP acknowledgement");
stream
.flush()
.expect("flush exact legacy HTTP acknowledgement");
}
#[cfg(all(unix, feature = "legacy-2024-11-05"))]
fn write_legacy_http_sse_message(stream: &mut TcpStream, message: &str) {
let event = format!("event: message\ndata: {message}\n\n");
write!(stream, "{:X}\r\n{event}\r\n", event.len())
.expect("write exact legacy SSE message event");
stream
.flush()
.expect("flush exact legacy SSE message event");
}
#[cfg(unix)]
fn assert_no_raw_extension_http_post(listener: &TcpListener) {
listener
.set_nonblocking(true)
.expect("configure raw extension no-contact assertion");
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(200);
while std::time::Instant::now() < deadline {
match listener.accept() {
Ok(_) => panic!("rejected raw extension request must not start an HTTP POST"),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(std::time::Duration::from_millis(2));
}
Err(error) => panic!("unexpected raw extension no-contact accept error: {error}"),
}
}
}
#[cfg(unix)]
#[test]
fn clt_ext_raw_http_public_request_uses_the_negotiated_registry() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind raw extension HTTP peer");
let address = listener
.local_addr()
.expect("read raw extension HTTP peer address");
let modern_target = format!("http://{address}/mcp");
let server = std::thread::spawn(move || {
let (mut probe, _) = listener.accept().expect("accept raw extension discovery");
let probe_request = read_http_cache_test_request(&mut probe);
assert_eq!(probe_request["id"], 1);
assert_eq!(probe_request["method"], "server/discover");
assert_eq!(
probe_request["params"]["_meta"][FINAL_CLIENT_CAPABILITIES_META_KEY]["extensions"]
["com.example/raw"],
serde_json::json!({ "mode": "raw" })
);
let discovery = modern_raw_extension_discovery_response(
"raw-extension-http-server",
Some(serde_json::json!({ "mode": "raw" })),
);
write_http_cache_test_response(&mut probe, "application/json", discovery.as_bytes());
let (mut request_stream, _) = listener.accept().expect("accept raw extension request");
let request = read_http_cache_test_request(&mut request_stream);
assert_eq!(request["id"], 2);
assert_eq!(request["method"], "example/echo");
assert_eq!(request["params"]["input"], "ok");
assert_eq!(
request["params"]["_meta"][FINAL_CLIENT_CAPABILITIES_META_KEY]["extensions"]["com.example/raw"],
serde_json::json!({ "mode": "raw" })
);
write_http_cache_test_response(
&mut request_stream,
"application/json",
br#"{"jsonrpc":"2.0","id":2,"result":{"echoed":{"input":"ok"},"resultType":"complete"}}"#,
);
});
let (extension_id, registry, discovery) = raw_extension_configuration(
ExtensionDirection::ClientToServer,
fastmcp_protocol::extensions::ExtensionFallbackPolicy::RejectOneSided,
);
let cx = Cx::for_request();
let mut client = http_test_runtime_block_on(
ClientBuilder::new()
.protocol_plan(http_cache_test_plan(&modern_target))
.extension_registry(registry, discovery, || accept_raw_extension_settings)
.expect("raw extension registry freezes before HTTP discovery")
.connect_http_client_with_cx(&cx),
)
.expect("public HTTP raw extension client completes discovery");
assert!(client.negotiated_extensions().is_some());
let result = http_test_runtime_block_on(client.request_final_extension(
&cx,
&extension_id,
"example/echo",
serde_json::json!({ "input": "ok" }),
))
.expect("public HTTP raw extension request uses the admitted source path");
assert_eq!(result["echoed"]["input"], "ok");
server.join().expect("raw extension HTTP peer joins");
}
#[cfg(unix)]
#[test]
fn clt_ext_raw_http_unnegotiated_settings_do_not_post() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind raw extension HTTP peer");
let address = listener
.local_addr()
.expect("read raw extension HTTP peer address");
let modern_target = format!("http://{address}/mcp");
let server = std::thread::spawn(move || {
let (mut probe, _) = listener.accept().expect("accept raw extension discovery");
let probe_request = read_http_cache_test_request(&mut probe);
assert_eq!(probe_request["method"], "server/discover");
let discovery =
modern_raw_extension_discovery_response("raw-extension-http-server", None);
write_http_cache_test_response(&mut probe, "application/json", discovery.as_bytes());
assert_no_raw_extension_http_post(&listener);
});
// This differs from the admitted descriptor only in the one-sided
// inactive fallback and the absent peer settings it selects.
let (extension_id, registry, discovery) = raw_extension_configuration(
ExtensionDirection::ClientToServer,
fastmcp_protocol::extensions::ExtensionFallbackPolicy::ClientInactiveFallback,
);
let cx = Cx::for_request();
let mut client = http_test_runtime_block_on(
ClientBuilder::new()
.protocol_plan(http_cache_test_plan(&modern_target))
.extension_registry(registry, discovery, || accept_raw_extension_settings)
.expect("inactive raw extension registry freezes before HTTP discovery")
.connect_http_client_with_cx(&cx),
)
.expect("public HTTP client retains inactive extension negotiation");
let next_id_before = client.next_id.load(Ordering::SeqCst);
let error = http_test_runtime_block_on(client.request_final_extension(
&cx,
&extension_id,
"example/echo",
serde_json::json!({}),
))
.expect_err("unnegotiated raw extension settings reject before HTTP ID allocation");
assert!(
matches!(error, HttpClientError::CoreResult(ref error) if error.code == McpErrorCode::InvalidParams)
);
assert_eq!(client.next_id.load(Ordering::SeqCst), next_id_before);
server.join().expect("raw extension no-post peer joins");
}
#[cfg(unix)]
#[test]
fn clt_ext_raw_http_wrong_direction_does_not_post() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind raw extension HTTP peer");
let address = listener
.local_addr()
.expect("read raw extension HTTP peer address");
let modern_target = format!("http://{address}/mcp");
let server = std::thread::spawn(move || {
let (mut probe, _) = listener.accept().expect("accept raw extension discovery");
let probe_request = read_http_cache_test_request(&mut probe);
assert_eq!(probe_request["method"], "server/discover");
let discovery = modern_raw_extension_discovery_response(
"raw-extension-http-server",
Some(serde_json::json!({ "mode": "raw" })),
);
write_http_cache_test_response(&mut probe, "application/json", discovery.as_bytes());
assert_no_raw_extension_http_post(&listener);
});
// This differs from the admitted descriptor only in method direction.
let (extension_id, registry, discovery) = raw_extension_configuration(
ExtensionDirection::ServerToClient,
fastmcp_protocol::extensions::ExtensionFallbackPolicy::RejectOneSided,
);
let cx = Cx::for_request();
let mut client = http_test_runtime_block_on(
ClientBuilder::new()
.protocol_plan(http_cache_test_plan(&modern_target))
.extension_registry(registry, discovery, || accept_raw_extension_settings)
.expect("server-owned raw descriptor freezes before HTTP discovery")
.connect_http_client_with_cx(&cx),
)
.expect("public HTTP client retains bilateral raw extension state");
let next_id_before = client.next_id.load(Ordering::SeqCst);
let error = http_test_runtime_block_on(client.request_final_extension(
&cx,
&extension_id,
"example/echo",
serde_json::json!({}),
))
.expect_err("server-owned raw method rejects before HTTP ID allocation");
assert!(
matches!(error, HttpClientError::CoreResult(ref error) if error.code == McpErrorCode::InvalidParams)
);
assert_eq!(client.next_id.load(Ordering::SeqCst), next_id_before);
server
.join()
.expect("wrong-direction raw extension no-post peer joins");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_ext_raw_legacy_http_request_cannot_bypass_extension_admission() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind exact legacy raw peer");
let address = listener
.local_addr()
.expect("read exact legacy raw peer address");
let sse_target = format!("http://{address}/legacy-sse");
let message_target = format!("http://{address}/legacy-message");
let advertised_message_target = message_target.clone();
let (ready_sender, ready_receiver) = mpsc::channel();
let (verify_sender, verify_receiver) = mpsc::channel();
let server = std::thread::spawn(move || {
let (mut sse, _) = listener.accept().expect("accept exact legacy SSE GET");
let mut request = [0_u8; 1_024];
let read = sse.read(&mut request).expect("read exact legacy SSE GET");
let request =
std::str::from_utf8(&request[..read]).expect("exact legacy SSE GET is UTF-8");
assert!(request.starts_with("GET /legacy-sse HTTP/1.1\r\n"));
let endpoint = format!("event: endpoint\ndata: {advertised_message_target}\n\n");
write!(
sse,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n{:X}\r\n{}\r\n",
endpoint.len(),
endpoint,
)
.expect("write exact legacy SSE endpoint event");
sse.flush().expect("flush exact legacy SSE endpoint event");
ready_sender
.send(())
.expect("exact legacy SSE endpoint is visible to the client");
verify_receiver
.recv_timeout(Duration::from_secs(1))
.expect("client reports the local raw extension refusal");
assert_no_raw_extension_http_post(&listener);
});
let (_extension_id, registry, discovery) = raw_extension_configuration(
ExtensionDirection::ClientToServer,
fastmcp_protocol::extensions::ExtensionFallbackPolicy::RejectOneSided,
);
let cx = Cx::for_request();
let mut connection = http_test_runtime_block_on(
ClientBuilder::new()
.protocol_plan(legacy_raw_extension_http_plan(&sse_target, &message_target))
.extension_registry(registry, discovery, || accept_raw_extension_settings)
.expect("raw extension registry is frozen before exact legacy selection")
.connect_http_with_cx(&cx),
)
.expect("exact legacy raw connection opens only its SSE lane");
ready_receiver
.recv_timeout(Duration::from_secs(1))
.expect("exact legacy peer exposes its message endpoint");
let Err(error) = http_test_runtime_block_on(connection.request(
&cx,
"example/echo",
serde_json::json!({}),
RequestId::Number(2),
)) else {
panic!("legacy raw request cannot bypass the registered final extension method");
};
assert!(matches!(
error,
ClientHttpConnectionError::RegisteredExtensionMethodRequiresAdmission { ref method }
if method == "example/echo"
));
verify_sender
.send(())
.expect("release the exact legacy no-contact assertion");
server
.join()
.expect("exact legacy raw extension peer joins without a message POST");
}
#[cfg(unix)]
#[test]
fn clt_01_http_completion_modern_preserves_full_typed_params_and_metadata() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind modern completion peer");
let address = listener
.local_addr()
.expect("read modern completion peer address");
let modern_target = format!("http://{address}/mcp");
let client_info = ClientInfo {
name: "http-completion-client".to_owned(),
version: "1.0.0".to_owned(),
};
let expected_metadata = serde_json::to_value(FinalRequestMeta {
protocol_version: MODERN_PROTOCOL_VERSION.to_owned(),
client_capabilities: ClientCapabilities::default(),
client_info: Some(client_info.to_implementation()),
additional_metadata: BTreeMap::new(),
})
.expect("exact modern completion metadata serializes");
let server = std::thread::spawn(move || {
let (mut discovery_stream, _) = listener
.accept()
.expect("accept modern completion discovery");
let discovery_request = read_http_cache_test_request(&mut discovery_stream);
assert_eq!(discovery_request["id"], 1);
assert_eq!(discovery_request["method"], "server/discover");
let discovery = modern_discovery_response(
"http-completion-modern-server",
&[MODERN_PROTOCOL_VERSION],
);
write_http_cache_test_response(
&mut discovery_stream,
"application/json",
discovery.as_bytes(),
);
let (mut completion_stream, _) =
listener.accept().expect("accept modern completion request");
let completion_request = read_http_cache_test_request(&mut completion_stream);
assert_eq!(completion_request["id"], 2);
assert_eq!(completion_request["method"], "completion/complete");
assert_eq!(
completion_request["params"]["ref"],
serde_json::json!({
"type": "ref/prompt",
"name": "deploy",
"title": "Deploy",
})
);
assert_eq!(
completion_request["params"]["context"],
serde_json::json!({"arguments": {"region": "us-east-1"}})
);
assert_eq!(completion_request["params"]["_meta"], expected_metadata);
write_http_cache_test_response(
&mut completion_stream,
"application/json",
br#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","completion":{"values":["staging"],"total":922337203685477580812345678901234567890,"hasMore":false}}}"#,
);
});
let cx = Cx::for_request();
let modern_plan = http_cache_test_plan(&modern_target);
let mut client = http_test_runtime_block_on(HttpClient::connect(
&cx,
modern_plan,
client_info,
ClientCapabilities::default(),
))
.expect("modern HTTP completion client completes discovery");
assert_eq!(client.protocol_plan().policy(), ProtocolPolicy::ModernOnly);
assert_eq!(client.selected_protocol_era(), ProtocolEra::Modern2026);
let result = http_test_runtime_block_on(client.complete(&cx, modern_completion_params()))
.expect("modern HTTP completion returns its typed final payload");
let CoreResult::Final(FinalCoreResult::Completion { result, diagnostic }) = result else {
panic!("modern HTTP completion must use the exact final decoder");
};
assert!(diagnostic.is_none());
assert_eq!(result.payload.completion.values, vec!["staging".to_owned()]);
assert_eq!(
result.payload.completion.total,
Some(
serde_json::from_str::<JsonInteger>("922337203685477580812345678901234567890")
.expect("arbitrary-precision completion total remains exact")
)
);
assert_eq!(result.payload.completion.has_more, Some(false));
server
.join()
.expect("modern HTTP completion peer joins after one request");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_01_http_completion_legacy_projects_only_the_compatible_subset() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind legacy completion peer");
let address = listener
.local_addr()
.expect("read legacy completion peer address");
let sse_target = format!("http://{address}/legacy-sse");
let message_target = format!("http://{address}/legacy-message");
let advertised_message_target = message_target.clone();
let server = std::thread::spawn(move || {
let (mut sse, _) = listener.accept().expect("accept legacy completion SSE");
let mut request = [0_u8; 1_024];
let read = sse
.read(&mut request)
.expect("read legacy completion SSE GET");
let request =
std::str::from_utf8(&request[..read]).expect("legacy completion SSE GET is UTF-8");
assert!(request.starts_with("GET /legacy-sse HTTP/1.1\r\n"));
let endpoint = format!("event: endpoint\ndata: {advertised_message_target}\n\n");
write!(
sse,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n{:X}\r\n{}\r\n",
endpoint.len(),
endpoint,
)
.expect("write legacy completion SSE endpoint");
sse.flush().expect("flush legacy completion SSE endpoint");
let (mut initialize_stream, _) = listener
.accept()
.expect("accept legacy completion initialize");
let initialize_request = read_http_cache_test_request(&mut initialize_stream);
assert_eq!(initialize_request["id"], 1);
assert_eq!(initialize_request["method"], "initialize");
write_legacy_http_accepted_response(&mut initialize_stream);
write_legacy_http_sse_message(
&mut sse,
r#"{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"legacy-completion-server","version":"1.0.0"}}}"#,
);
let (mut initialized_stream, _) = listener
.accept()
.expect("accept legacy completion initialized notification");
let initialized_request = read_http_cache_test_request(&mut initialized_stream);
assert!(initialized_request.get("id").is_none());
assert_eq!(initialized_request["method"], "notifications/initialized");
write_legacy_http_accepted_response(&mut initialized_stream);
let (mut completion_stream, _) =
listener.accept().expect("accept legacy completion request");
let completion_request = read_http_cache_test_request(&mut completion_stream);
assert_eq!(completion_request["id"], 2);
assert_eq!(completion_request["method"], "completion/complete");
assert_eq!(
completion_request["params"],
serde_json::json!({
"ref": {"type": "ref/prompt", "name": "deploy"},
"argument": {"name": "environment", "value": "sta"},
})
);
write_legacy_http_accepted_response(&mut completion_stream);
write_legacy_http_sse_message(
&mut sse,
r#"{"jsonrpc":"2.0","id":2,"result":{"completion":{"values":["staging"],"total":1,"hasMore":false}}}"#,
);
});
let cx = Cx::for_request();
let result = http_test_runtime_block_on(async {
let mut client = HttpClient::connect(
&cx,
legacy_completion_http_plan(&sse_target, &message_target),
ClientInfo {
name: "legacy-completion-client".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
)
.await
.expect("legacy HTTP completion client completes its lifecycle");
assert_eq!(client.selected_protocol_era(), ProtocolEra::Legacy2024);
client
.complete(&cx, completion_params())
.await
.expect("legacy HTTP completion keeps the compatible exact-2024 shape")
});
let CoreResult::Legacy(LegacyCoreResult::Completion(result)) = result else {
panic!("legacy HTTP completion must use the exact legacy decoder");
};
assert_eq!(result.completion.values, vec!["staging".to_owned()]);
assert_eq!(result.completion.total, Some(1));
assert_eq!(result.completion.has_more, Some(false));
server
.join()
.expect("legacy HTTP completion peer joins after one request");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_01_http_completion_legacy_rejects_prompt_title_before_id_or_post() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind legacy completion peer");
let address = listener
.local_addr()
.expect("read legacy completion peer address");
let sse_target = format!("http://{address}/legacy-sse");
let message_target = format!("http://{address}/legacy-message");
let advertised_message_target = message_target.clone();
let (ready_sender, ready_receiver) = mpsc::channel();
let (observe_sender, observe_receiver) = mpsc::channel();
let completion_contacts = Arc::new(AtomicUsize::new(0));
let observed_completion_contacts = Arc::clone(&completion_contacts);
let server = std::thread::spawn(move || {
let (mut sse, _) = listener.accept().expect("accept legacy completion SSE");
let mut request = [0_u8; 1_024];
let read = sse
.read(&mut request)
.expect("read legacy completion SSE GET");
let request =
std::str::from_utf8(&request[..read]).expect("legacy completion SSE GET is UTF-8");
assert!(request.starts_with("GET /legacy-sse HTTP/1.1\r\n"));
let endpoint = format!("event: endpoint\ndata: {advertised_message_target}\n\n");
write!(
sse,
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n{:X}\r\n{}\r\n",
endpoint.len(),
endpoint,
)
.expect("write legacy completion SSE endpoint");
sse.flush().expect("flush legacy completion SSE endpoint");
let (mut initialize_stream, _) = listener
.accept()
.expect("accept legacy completion initialize");
let initialize_request = read_http_cache_test_request(&mut initialize_stream);
assert_eq!(initialize_request["method"], "initialize");
write_legacy_http_accepted_response(&mut initialize_stream);
write_legacy_http_sse_message(
&mut sse,
r#"{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"legacy-completion-server","version":"1.0.0"}}}"#,
);
let (mut initialized_stream, _) = listener
.accept()
.expect("accept legacy completion initialized notification");
let initialized_request = read_http_cache_test_request(&mut initialized_stream);
assert_eq!(initialized_request["method"], "notifications/initialized");
write_legacy_http_accepted_response(&mut initialized_stream);
ready_sender
.send(())
.expect("legacy completion client reaches the zero-contact gate");
observe_receiver
.recv_timeout(Duration::from_secs(1))
.expect("test releases the legacy completion no-contact observer");
listener
.set_nonblocking(true)
.expect("configure legacy completion no-contact observer");
let deadline = Instant::now() + Duration::from_millis(200);
while Instant::now() < deadline {
match listener.accept() {
Ok((_stream, _)) => {
observed_completion_contacts.fetch_add(1, Ordering::SeqCst);
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(2));
}
Err(error) => panic!("observe legacy completion contact: {error}"),
}
}
});
let cx = Cx::for_request();
let mut client = http_test_runtime_block_on(HttpClient::connect(
&cx,
legacy_completion_http_plan(&sse_target, &message_target),
ClientInfo {
name: "legacy-completion-client".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
))
.expect("legacy HTTP completion client completes its lifecycle");
ready_receiver
.recv_timeout(Duration::from_secs(1))
.expect("legacy completion peer reaches the zero-contact gate");
let next_id_before = client.next_id.load(Ordering::SeqCst);
let mut title_only = completion_params();
title_only.reference = CompletionReference::PromptWithTitle {
name: "deploy".to_owned(),
title: "Deploy".to_owned(),
};
let error = http_test_runtime_block_on(client.complete(&cx, title_only))
.expect_err("changing only the title must reject before a legacy completion POST");
assert!(matches!(
error,
HttpClientError::CoreResult(ref error) if error.code == McpErrorCode::InvalidParams
));
assert_eq!(client.next_id.load(Ordering::SeqCst), next_id_before);
observe_sender
.send(())
.expect("release legacy completion no-contact observer");
server
.join()
.expect("legacy completion no-contact peer joins");
assert_eq!(
completion_contacts.load(Ordering::SeqCst),
0,
"an unrepresentable title must not open a legacy completion message POST"
);
}
#[cfg(unix)]
#[test]
fn public_http_mrtr_retry_drives_tool_resource_and_prompt_to_terminal_results() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind public MRTR listener");
let address = listener.local_addr().expect("read public MRTR address");
let modern_target = format!("http://{address}/mcp");
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept public MRTR discovery");
let probe = read_http_cache_test_request(&mut stream);
assert_eq!(probe["id"], 1);
assert_eq!(probe["method"], "server/discover");
let discovery =
modern_discovery_response("public-http-mrtr-server", &[MODERN_PROTOCOL_VERSION]);
write_http_cache_test_response(&mut stream, "application/json", discovery.as_bytes());
for (method, request_id, expected_state, next_state) in [
("tools/call", 2, None, Some("tool-one")),
("tools/call", 3, Some("tool-one"), Some("tool-two")),
("tools/call", 4, Some("tool-two"), None),
("resources/read", 5, None, Some("resource-one")),
(
"resources/read",
6,
Some("resource-one"),
Some("resource-two"),
),
("resources/read", 7, Some("resource-two"), None),
("prompts/get", 8, None, Some("prompt-one")),
("prompts/get", 9, Some("prompt-one"), Some("prompt-two")),
("prompts/get", 10, Some("prompt-two"), None),
] {
let (mut stream, _) = listener.accept().expect("accept public MRTR round");
let request = read_http_cache_test_request(&mut stream);
assert_eq!(request["id"], request_id);
assert_eq!(request["method"], method);
assert!(
request["params"]["_meta"]["io.modelcontextprotocol/clientCapabilities"]
.get("extensions")
.is_none(),
"ordinary MRTR must not negotiate Tasks"
);
match expected_state {
Some(state) => {
assert_eq!(request["params"]["requestState"], state);
assert!(request["params"].get("inputResponses").is_none());
}
None => {
assert!(request["params"].get("requestState").is_none());
assert!(request["params"].get("inputResponses").is_none());
}
}
let response = match next_state {
Some(state) => format!(
"{{\"jsonrpc\":\"2.0\",\"id\":{request_id},\"result\":{{\"resultType\":\"input_required\",\"requestState\":\"{state}\"}}}}"
),
None => match method {
"tools/call" => format!(
"{{\"jsonrpc\":\"2.0\",\"id\":{request_id},\"result\":{{\"resultType\":\"complete\",\"content\":[{{\"type\":\"text\",\"text\":\"done\"}}]}}}}"
),
"resources/read" => format!(
"{{\"jsonrpc\":\"2.0\",\"id\":{request_id},\"result\":{{\"resultType\":\"complete\",\"contents\":[],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}"
),
"prompts/get" => format!(
"{{\"jsonrpc\":\"2.0\",\"id\":{request_id},\"result\":{{\"resultType\":\"complete\",\"messages\":[]}}}}"
),
_ => unreachable!("the test covers only MRTR core methods"),
},
};
write_http_cache_test_response(
&mut stream,
"application/json",
response.as_bytes(),
);
}
});
let cx = Cx::for_request();
let mut client = http_test_runtime_block_on(HttpClient::connect(
&cx,
http_cache_test_plan(&modern_target),
ClientInfo {
name: "public-http-mrtr-client".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
))
.expect("public HTTP client completes discovery");
let sse_limits = sse::SseLimits::new(1_024, 8_192, 8).expect("valid SSE limits");
let mut tool_states = Vec::new();
let tool = http_test_runtime_block_on(client.call_tool_with_mrtr_retry(
&cx,
Instant::now() + Duration::from_secs(2),
"public-tool",
serde_json::json!({"input": "state-only"}),
sse_limits,
4_096,
|input_required| {
tool_states.push(
input_required
.request_state()
.expect("tool continuation carries state")
.to_owned(),
);
Ok(BTreeMap::new())
},
))
.expect("public HTTP tool MRTR reaches a terminal result");
assert!(matches!(tool, FinalCoreResult::ToolsCall { .. }));
assert_eq!(tool_states, ["tool-one", "tool-two"]);
let mut resource_states = Vec::new();
let resource = http_test_runtime_block_on(client.read_resource_with_mrtr_retry(
&cx,
Instant::now() + Duration::from_secs(2),
"file:///public-mrtr.txt",
sse_limits,
4_096,
|input_required| {
resource_states.push(
input_required
.request_state()
.expect("resource continuation carries state")
.to_owned(),
);
Ok(BTreeMap::new())
},
))
.expect("public HTTP resource MRTR reaches a terminal result");
assert!(matches!(resource, FinalCoreResult::ResourcesRead { .. }));
assert_eq!(resource_states, ["resource-one", "resource-two"]);
let mut prompt_states = Vec::new();
let prompt = http_test_runtime_block_on(client.get_prompt_with_mrtr_retry(
&cx,
Instant::now() + Duration::from_secs(2),
"public-prompt",
HashMap::new(),
sse_limits,
4_096,
|input_required| {
prompt_states.push(
input_required
.request_state()
.expect("prompt continuation carries state")
.to_owned(),
);
Ok(BTreeMap::new())
},
))
.expect("public HTTP prompt MRTR reaches a terminal result");
assert!(matches!(prompt, FinalCoreResult::PromptsGet { .. }));
assert_eq!(prompt_states, ["prompt-one", "prompt-two"]);
server.join().expect("public HTTP MRTR peer joins");
}
#[cfg(unix)]
fn assert_public_http_mrtr_round_bound(final_round_is_terminal: bool) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind public MRTR bound listener");
let address = listener
.local_addr()
.expect("read public MRTR bound address");
let modern_target = format!("http://{address}/mcp");
let server = std::thread::spawn(move || {
let (mut stream, _) = listener
.accept()
.expect("accept public MRTR bound discovery");
let probe = read_http_cache_test_request(&mut stream);
assert_eq!(probe["id"], 1);
let discovery = modern_discovery_response(
"public-http-mrtr-bound-server",
&[MODERN_PROTOCOL_VERSION],
);
write_http_cache_test_response(&mut stream, "application/json", discovery.as_bytes());
for request_id in 2..=(MAX_MRTR_CONTINUATION_ROUNDS as i64 + 2) {
let (mut stream, _) = listener.accept().expect("accept public MRTR bound round");
let request = read_http_cache_test_request(&mut stream);
assert_eq!(request["id"], request_id);
assert_eq!(request["method"], "tools/call");
assert!(
request["params"]["_meta"]["io.modelcontextprotocol/clientCapabilities"]
.get("extensions")
.is_none(),
"ordinary MRTR must not negotiate Tasks"
);
let response = if final_round_is_terminal
&& request_id == MAX_MRTR_CONTINUATION_ROUNDS as i64 + 2
{
format!(
"{{\"jsonrpc\":\"2.0\",\"id\":{request_id},\"result\":{{\"resultType\":\"complete\",\"content\":[]}}}}"
)
} else {
format!(
"{{\"jsonrpc\":\"2.0\",\"id\":{request_id},\"result\":{{\"resultType\":\"input_required\",\"requestState\":\"round-{request_id}\"}}}}"
)
};
write_http_cache_test_response(
&mut stream,
"application/json",
response.as_bytes(),
);
}
if !final_round_is_terminal {
listener
.set_nonblocking(true)
.expect("configure public MRTR no-contact assertion");
let no_contact_deadline = Instant::now() + Duration::from_millis(200);
while Instant::now() < no_contact_deadline {
match listener.accept() {
Ok(_) => panic!("MRTR round bound must reject before a sixth POST"),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(2));
}
Err(error) => panic!("unexpected public MRTR no-contact error: {error}"),
}
}
}
});
let cx = Cx::for_request();
let mut client = http_test_runtime_block_on(HttpClient::connect(
&cx,
http_cache_test_plan(&modern_target),
ClientInfo {
name: "public-http-mrtr-bound-client".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
))
.expect("public HTTP bound client completes discovery");
let mut callback_count = 0_usize;
let result = http_test_runtime_block_on(client.call_tool_with_mrtr_retry(
&cx,
Instant::now() + Duration::from_secs(2),
"bound-tool",
serde_json::json!({}),
sse::SseLimits::new(1_024, 8_192, 8).expect("valid SSE limits"),
4_096,
|_| {
callback_count += 1;
Ok(BTreeMap::new())
},
));
if final_round_is_terminal {
assert!(matches!(result, Ok(FinalCoreResult::ToolsCall { .. })));
} else {
assert!(matches!(
result,
Err(HttpClientError::Connection(ClientHttpConnectionError::Mrtr(
http_executor::ModernHttpMrtrError::Driver(ref error)
))) if error.message == "MRTR continuation-round limit exceeded"
));
}
assert_eq!(callback_count, MAX_MRTR_CONTINUATION_ROUNDS);
server.join().expect("public HTTP MRTR bound peer joins");
}
#[cfg(unix)]
#[test]
fn public_http_mrtr_retry_round_bound_accepts_the_terminal_fifth_response() {
assert_public_http_mrtr_round_bound(true);
}
#[cfg(unix)]
#[test]
fn public_http_mrtr_retry_round_bound_rejects_only_an_input_required_fifth_response_without_contact()
{
assert_public_http_mrtr_round_bound(false);
}
#[cfg(unix)]
fn public_http_sampling_input_required_body(request_id: i64) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({
"jsonrpc": "2.0",
"id": request_id,
"result": {
"resultType": "input_required",
"inputRequests": {
"sample": {
"method": "sampling/createMessage",
"params": {
"messages": [{
"role": "user",
"content": { "type": "text", "text": "hello" }
}],
"maxTokens": 8
}
}
},
"requestState": "retry-sample"
}
}))
.expect("sampling input_required fixture serializes")
}
#[cfg(unix)]
fn public_http_sampling_handler() -> ReverseRequestHandlers {
ReverseRequestHandlers::new().with_modern_sampling_create_message(
|_cx, _cancellation, params| {
Box::pin(async move {
assert_eq!(params.max_tokens.to_string(), "8");
Ok(FinalCreateMessageResult {
content: fastmcp_protocol::FinalSamplingMessageContent::Block(
fastmcp_protocol::common_types::SamplingContentBlock::Text {
text: "sampled".to_owned(),
annotations: None,
meta: None,
additional: BTreeMap::new(),
},
),
model: "public-http-mrtr-model".to_owned(),
role: fastmcp_protocol::Role::Assistant,
stop_reason: None,
meta: None,
})
})
},
)
}
#[cfg(unix)]
#[test]
fn public_http_call_tool_follows_input_required_with_installed_sampling_handler() {
let listener =
TcpListener::bind("127.0.0.1:0").expect("bind public call_tool MRTR listener");
let address = listener
.local_addr()
.expect("read public call_tool MRTR address");
let modern_target = format!("http://{address}/mcp");
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept discovery");
let probe = read_http_cache_test_request(&mut stream);
assert_eq!(probe["method"], "server/discover");
let discovery =
modern_discovery_response("public-http-call-tool-mrtr", &[MODERN_PROTOCOL_VERSION]);
write_http_cache_test_response(&mut stream, "application/json", discovery.as_bytes());
let (mut stream, _) = listener.accept().expect("accept initial tools/call");
let initial = read_http_cache_test_request(&mut stream);
assert_eq!(initial["id"], 2);
assert_eq!(initial["method"], "tools/call");
assert!(initial["params"].get("inputResponses").is_none());
write_http_cache_test_response(
&mut stream,
"application/json",
&public_http_sampling_input_required_body(2),
);
let (mut stream, _) = listener.accept().expect("accept handler-driven retry");
let retry = read_http_cache_test_request(&mut stream);
assert_eq!(retry["id"], 3);
assert_eq!(retry["method"], "tools/call");
assert_eq!(retry["params"]["requestState"], "retry-sample");
assert_eq!(
retry["params"]["inputResponses"]["sample"]["model"],
"public-http-mrtr-model"
);
assert_eq!(
retry["params"]["inputResponses"]["sample"]["content"]["text"],
"sampled"
);
write_http_cache_test_response(
&mut stream,
"application/json",
br#"{"jsonrpc":"2.0","id":3,"result":{"resultType":"complete","content":[{"type":"text","text":"sampled-tool"}]}}"#,
);
});
let cx = Cx::for_request();
let mut client = http_test_runtime_block_on(
ClientBuilder::new()
.protocol_plan(http_cache_test_plan(&modern_target))
.client_info("public-http-call-tool-mrtr", "1.0.0")
.reverse_request_handlers(public_http_sampling_handler())
.connect_http_client_with_cx(&cx),
)
.expect("public HTTP client connects with sampling handlers");
let result =
http_test_runtime_block_on(client.call_tool(&cx, "sample-tool", serde_json::json!({})))
.expect("public call_tool follows sampling input_required to a terminal result");
assert!(matches!(
result,
CoreResult::Final(FinalCoreResult::ToolsCall { .. })
));
server.join().expect("public call_tool MRTR peer must join");
}
#[cfg(unix)]
#[test]
fn public_http_call_tool_returns_input_required_when_no_reverse_handlers_are_installed() {
let listener =
TcpListener::bind("127.0.0.1:0").expect("bind public call_tool no-handler listener");
let address = listener
.local_addr()
.expect("read public call_tool no-handler address");
let modern_target = format!("http://{address}/mcp");
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept discovery");
let probe = read_http_cache_test_request(&mut stream);
assert_eq!(probe["method"], "server/discover");
let discovery = modern_discovery_response(
"public-http-call-tool-no-handler",
&[MODERN_PROTOCOL_VERSION],
);
write_http_cache_test_response(&mut stream, "application/json", discovery.as_bytes());
let (mut stream, _) = listener.accept().expect("accept tools/call");
let request = read_http_cache_test_request(&mut stream);
assert_eq!(request["id"], 2);
assert_eq!(request["method"], "tools/call");
write_http_cache_test_response(
&mut stream,
"application/json",
&public_http_sampling_input_required_body(2),
);
listener
.set_nonblocking(true)
.expect("configure no-handler no-contact assertion");
let no_contact_deadline = Instant::now() + Duration::from_millis(200);
while Instant::now() < no_contact_deadline {
match listener.accept() {
Ok(_) => panic!(
"call_tool without reverse handlers must not retry an input_required result"
),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(2));
}
Err(error) => panic!("unexpected no-handler no-contact error: {error}"),
}
}
});
let cx = Cx::for_request();
let mut client = http_test_runtime_block_on(HttpClient::connect(
&cx,
http_cache_test_plan(&modern_target),
ClientInfo {
name: "public-http-call-tool-no-handler".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
))
.expect("public HTTP client completes discovery");
let result =
http_test_runtime_block_on(client.call_tool(&cx, "sample-tool", serde_json::json!({})))
.expect("call_tool without handlers returns the input_required result");
assert!(matches!(
result,
CoreResult::Final(FinalCoreResult::ToolsCallInputRequired { .. })
));
server
.join()
.expect("public call_tool no-handler peer must join");
}
#[cfg(unix)]
#[test]
fn public_http_call_tool_rejects_unhandled_sampling_input_before_retry() {
let listener = TcpListener::bind("127.0.0.1:0")
.expect("bind public call_tool missing-handler listener");
let address = listener
.local_addr()
.expect("read public call_tool missing-handler address");
let modern_target = format!("http://{address}/mcp");
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept discovery");
let probe = read_http_cache_test_request(&mut stream);
assert_eq!(probe["method"], "server/discover");
let discovery = modern_discovery_response(
"public-http-call-tool-missing-handler",
&[MODERN_PROTOCOL_VERSION],
);
write_http_cache_test_response(&mut stream, "application/json", discovery.as_bytes());
let (mut stream, _) = listener.accept().expect("accept tools/call");
let request = read_http_cache_test_request(&mut stream);
assert_eq!(request["id"], 2);
assert_eq!(request["method"], "tools/call");
write_http_cache_test_response(
&mut stream,
"application/json",
&public_http_sampling_input_required_body(2),
);
listener
.set_nonblocking(true)
.expect("configure missing-handler no-contact assertion");
let no_contact_deadline = Instant::now() + Duration::from_millis(200);
while Instant::now() < no_contact_deadline {
match listener.accept() {
Ok(_) => panic!("a missing sampling handler must reject before a retry POST"),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(2));
}
Err(error) => panic!("unexpected missing-handler no-contact error: {error}"),
}
}
});
let cx = Cx::for_request();
let handlers =
ReverseRequestHandlers::new().with_modern_roots_list(|_cx, _cancellation, _params| {
Box::pin(async move { Ok(FinalEmbeddedRootsListResult { roots: Vec::new() }) })
});
let mut client = http_test_runtime_block_on(
ClientBuilder::new()
.protocol_plan(http_cache_test_plan(&modern_target))
.client_info("public-http-call-tool-missing-handler", "1.0.0")
.reverse_request_handlers(handlers)
.connect_http_client_with_cx(&cx),
)
.expect("public HTTP client connects with only roots handlers");
let error =
http_test_runtime_block_on(client.call_tool(&cx, "sample-tool", serde_json::json!({})))
.expect_err("missing sampling handler must reject the input_required result");
match error {
HttpClientError::CoreResult(error) => {
assert!(
error.message.contains("sampling/createMessage"),
"missing handler error must name the requested method: {}",
error.message
);
}
other => panic!("expected a core result error, got {other}"),
}
server
.join()
.expect("public call_tool missing-handler peer must join");
}
#[cfg(unix)]
fn http_tools_list_response(id: i64, tool_name: &str, ttl_ms: u64) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"resultType": "complete",
"tools": [{
"name": tool_name,
"inputSchema": {"type": "object"}
}],
"ttlMs": ttl_ms,
"cacheScope": "private"
}
}))
.expect("HTTP cache tools/list response serializes")
}
#[cfg(unix)]
fn assert_http_subscription_cache_invalidation(acknowledges_tools_list_changes: bool) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind HTTP cache listener");
let address = listener
.local_addr()
.expect("read HTTP cache listener address");
let modern_target = format!("http://{address}/mcp");
let discovery = modern_discovery_response(
"http-subscription-cache-modern-server",
&[MODERN_PROTOCOL_VERSION],
);
let initial = http_tools_list_response(2, "cached", 1_000);
let refreshed = http_tools_list_response(4, "refreshed", 1_000);
let server = std::thread::spawn(move || {
let (mut probe, _) = listener.accept().expect("accept HTTP discovery");
let probe_request = read_http_cache_test_request(&mut probe);
assert_eq!(probe_request["id"], 1);
assert_eq!(probe_request["method"], "server/discover");
write_http_cache_test_response(&mut probe, "application/json", discovery.as_bytes());
let (mut first_list, _) = listener.accept().expect("accept initial tools/list");
let first_list_request = read_http_cache_test_request(&mut first_list);
assert_eq!(first_list_request["id"], 2);
assert_eq!(first_list_request["method"], "tools/list");
write_http_cache_test_response(&mut first_list, "application/json", &initial);
let (mut listen, _) = listener.accept().expect("accept subscriptions/listen");
let listen_request = read_http_cache_test_request(&mut listen);
assert_eq!(listen_request["id"], 3);
assert_eq!(listen_request["method"], "subscriptions/listen");
assert_eq!(
listen_request["params"]["notifications"]["toolsListChanged"],
true
);
let acknowledgement_filter = if acknowledges_tools_list_changes {
r#"{"toolsListChanged":true}"#
} else {
// This differs only by the accepted tools-list change field.
"{}"
};
let terminal = r#"{"jsonrpc":"2.0","id":3,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/subscriptionId":3}}}"#;
let sse = format!(
"data: {{\"jsonrpc\":\"2.0\",\"method\":\"notifications/subscriptions/acknowledged\",\"params\":{{\"_meta\":{{\"io.modelcontextprotocol/subscriptionId\":3}},\"notifications\":{acknowledgement_filter}}}}}\n\ndata: {{\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}}\n\n{}",
acknowledges_tools_list_changes
.then_some(format!("data: {terminal}\n\n"))
.unwrap_or_default(),
);
write_http_cache_test_response(&mut listen, "text/event-stream", sse.as_bytes());
if acknowledges_tools_list_changes {
let (mut second_list, _) = listener
.accept()
.expect("accepted change forces a second tools/list");
let second_list_request = read_http_cache_test_request(&mut second_list);
assert_eq!(second_list_request["id"], 4);
assert_eq!(second_list_request["method"], "tools/list");
write_http_cache_test_response(&mut second_list, "application/json", &refreshed);
}
});
let cx = Cx::for_request();
let mut client = http_test_runtime_block_on(HttpClient::connect(
&cx,
http_cache_test_plan(&modern_target),
ClientInfo {
name: "http-cache-test-client".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
))
.expect("public HTTP client completes discovery");
let initial_result = http_test_runtime_block_on(client.request_final_core(
&cx,
"tools/list",
serde_json::json!({}),
))
.expect("initial public HTTP tools/list fills the cache");
assert!(
initial_result
.encode()
.expect("initial typed result re-encodes")
.contains("cached")
);
let filter = SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
};
let limits =
sse::SseLimits::new(1_024, 8_192, 16).expect("explicit SSE bounds are nonzero");
if acknowledges_tools_list_changes {
let listener =
http_test_runtime_block_on(client.open_subscriptions_listener(&cx, filter, limits))
.expect("public HTTP listener opens");
let mut listener = listener;
assert!(matches!(
http_test_runtime_block_on(listener.next_event(&cx))
.expect("listener acknowledgement is accepted"),
Some(ModernHttpSubscriptionListenEvent::Acknowledged { .. })
));
assert!(matches!(
http_test_runtime_block_on(listener.next_event(&cx))
.expect("accepted HTTP change event is yielded live"),
Some(ModernHttpSubscriptionListenEvent::Notification(
ServerNotification::ToolsListChanged(None)
))
));
assert_eq!(listener.final_result_cache_stats().invalidations, 1);
drop(listener);
let refreshed_result = http_test_runtime_block_on(client.request_final_core(
&cx,
"tools/list",
serde_json::json!({}),
))
.expect("accepted change invalidates the public HTTP cache before the next hit");
assert!(
refreshed_result
.encode()
.expect("refreshed typed result re-encodes")
.contains("refreshed")
);
assert_eq!(client.final_result_cache_stats().hits, 0);
assert_eq!(client.final_result_cache_stats().fills, 2);
assert_eq!(client.final_result_cache_stats().invalidations, 1);
} else {
let listener =
http_test_runtime_block_on(client.open_subscriptions_listener(&cx, filter, limits))
.expect("public HTTP listener opens before the planted filter mismatch");
let mut listener = listener;
assert!(matches!(
http_test_runtime_block_on(listener.next_event(&cx))
.expect("empty acknowledgement itself is admitted"),
Some(ModernHttpSubscriptionListenEvent::Acknowledged { .. })
));
let error = http_test_runtime_block_on(listener.next_event(&cx))
.expect_err("one omitted accepted-filter field rejects the event");
assert!(matches!(
error,
HttpClientError::Connection(ClientHttpConnectionError::SubscriptionsListen(
http_executor::ModernHttpSubscriptionListenError::EventOutsideAcceptedFilter
))
));
assert_eq!(listener.final_result_cache_stats().invalidations, 0);
drop(listener);
let cached_result = http_test_runtime_block_on(client.request_final_core(
&cx,
"tools/list",
serde_json::json!({}),
))
.expect("rejected change leaves the public HTTP cache unchanged");
assert!(
cached_result
.encode()
.expect("cached typed result re-encodes")
.contains("cached")
);
assert_eq!(client.final_result_cache_stats().hits, 1);
assert_eq!(client.final_result_cache_stats().fills, 1);
assert_eq!(client.final_result_cache_stats().invalidations, 0);
}
server
.join()
.expect("HTTP subscription cache server must join");
}
#[cfg(unix)]
#[test]
fn cache_03_public_http_subscription_change_invalidates_the_connection_cache() {
assert_http_subscription_cache_invalidation(true);
}
#[cfg(unix)]
#[test]
fn cache_03_public_http_subscription_rejects_one_unacknowledged_change_field() {
assert_http_subscription_cache_invalidation(false);
}
#[cfg(unix)]
#[test]
fn cache_03_public_http_incremental_listen_keeps_issuing_tools_list() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind incremental HTTP listener");
let address = listener
.local_addr()
.expect("read incremental HTTP listener address");
let modern_target = format!("http://{address}/mcp");
let discovery = modern_discovery_response(
"http-incremental-listen-modern-server",
&[MODERN_PROTOCOL_VERSION],
);
let initial = http_tools_list_response(2, "cached", 1_000);
let refreshed = http_tools_list_response(4, "refreshed", 1_000);
let server = std::thread::spawn(move || {
let (mut probe, _) = listener.accept().expect("accept HTTP discovery");
let _ = read_http_cache_test_request(&mut probe);
write_http_cache_test_response(&mut probe, "application/json", discovery.as_bytes());
let (mut first_list, _) = listener.accept().expect("accept initial tools/list");
let _ = read_http_cache_test_request(&mut first_list);
write_http_cache_test_response(&mut first_list, "application/json", &initial);
let (mut listen, _) = listener.accept().expect("accept subscriptions/listen");
let _ = read_http_cache_test_request(&mut listen);
let terminal = r#"{"jsonrpc":"2.0","id":3,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/subscriptionId":3}}}"#;
let sse = format!(
"data: {{\"jsonrpc\":\"2.0\",\"method\":\"notifications/subscriptions/acknowledged\",\"params\":{{\"_meta\":{{\"io.modelcontextprotocol/subscriptionId\":3}},\"notifications\":{{\"toolsListChanged\":true}}}}}}\n\ndata: {{\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}}\n\ndata: {terminal}\n\n"
);
write_http_cache_test_response(&mut listen, "text/event-stream", sse.as_bytes());
let (mut second_list, _) = listener
.accept()
.expect("incremental listen must not block a later tools/list");
let _ = read_http_cache_test_request(&mut second_list);
write_http_cache_test_response(&mut second_list, "application/json", &refreshed);
});
let cx = Cx::for_request();
let mut client = http_test_runtime_block_on(HttpClient::connect(
&cx,
http_cache_test_plan(&modern_target),
ClientInfo {
name: "http-incremental-listen-client".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
))
.expect("public HTTP client completes discovery");
let _ = http_test_runtime_block_on(client.request_final_core(
&cx,
"tools/list",
serde_json::json!({}),
))
.expect("initial public HTTP tools/list fills the cache");
let filter = SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
};
let limits =
sse::SseLimits::new(1_024, 8_192, 16).expect("explicit SSE bounds are nonzero");
http_test_runtime_block_on(client.start_subscriptions_listener(&cx, filter, limits))
.expect("incremental HTTP listener commits without borrowing the cache");
assert!(matches!(
http_test_runtime_block_on(client.next_http_subscription_event(&cx))
.expect("incremental acknowledgement is accepted"),
Some(ModernHttpSubscriptionListenEvent::Acknowledged { .. })
));
assert!(matches!(
http_test_runtime_block_on(client.next_http_subscription_event(&cx))
.expect("incremental catalog event is accepted"),
Some(ModernHttpSubscriptionListenEvent::Notification(
ServerNotification::ToolsListChanged(None)
))
));
let refreshed_result = http_test_runtime_block_on(client.request_final_core(
&cx,
"tools/list",
serde_json::json!({}),
))
.expect("the same HTTP client must list tools while the listener is still installed");
assert!(
refreshed_result
.encode()
.expect("refreshed typed result re-encodes")
.contains("refreshed")
);
server
.join()
.expect("incremental HTTP subscription server must join");
}
#[cfg(unix)]
#[cfg(feature = "tasks")]
fn run_public_http_final_task_handle_lifecycle(
notification_task_id: &str,
) -> (FinalTask, Option<HttpClientError>, u64) {
let listener =
TcpListener::bind("127.0.0.1:0").expect("bind public final task handle listener");
let address = listener
.local_addr()
.expect("read public final task handle listener address");
let modern_target = format!("http://{address}/mcp");
let notification_task_id = notification_task_id.to_owned();
let notification_matches_handle = notification_task_id == "task-73";
let (negative_done_tx, negative_done_rx) = mpsc::sync_channel(1);
let server = std::thread::spawn(move || {
let discovery = modern_tasks_discovery_response(
"public-final-task-handle-server",
serde_json::json!({}),
);
let (mut probe, _) = listener
.accept()
.expect("accept final task handle discovery");
let probe_request = read_http_cache_test_request(&mut probe);
assert_eq!(probe_request["id"], 1);
assert_eq!(probe_request["method"], "server/discover");
write_http_cache_test_response(&mut probe, "application/json", discovery.as_bytes());
let (mut get, _) = listener
.accept()
.expect("accept final task handle attachment");
let get_request = read_http_cache_test_request(&mut get);
assert_eq!(get_request["id"], 2);
assert_eq!(get_request["method"], "tasks/get");
assert_eq!(get_request["params"]["taskId"], "task-73");
assert_eq!(
get_request["params"]["_meta"]["io.modelcontextprotocol/clientCapabilities"]["extensions"]
["io.modelcontextprotocol/tasks"],
serde_json::json!({})
);
write_http_cache_test_response(
&mut get,
"application/json",
br#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","taskId":"task-73","status":"working","createdAt":"2026-07-28T12:00:00Z","lastUpdatedAt":"2026-07-28T12:00:00Z","ttlMs":null,"pollIntervalMs":25}}"#,
);
let (mut watch, _) = listener
.accept()
.expect("accept final task handle subscription");
let watch_request = read_http_cache_test_request(&mut watch);
assert_eq!(watch_request["id"], 3);
assert_eq!(watch_request["method"], "subscriptions/listen");
assert_eq!(
watch_request["params"]["notifications"]["taskIds"],
serde_json::json!(["task-73"])
);
assert_eq!(
watch_request["params"]["_meta"]["io.modelcontextprotocol/clientCapabilities"]["extensions"]
["io.modelcontextprotocol/tasks"],
serde_json::json!({})
);
let sse = format!(
"data: {{\"jsonrpc\":\"2.0\",\"method\":\"notifications/subscriptions/acknowledged\",\"params\":{{\"_meta\":{{\"io.modelcontextprotocol/subscriptionId\":3}},\"notifications\":{{\"taskIds\":[\"task-73\"]}}}}}}\n\ndata: {{\"jsonrpc\":\"2.0\",\"method\":\"notifications/tasks\",\"params\":{{\"_meta\":{{\"io.modelcontextprotocol/subscriptionId\":3}},\"taskId\":\"{notification_task_id}\",\"status\":\"input_required\",\"createdAt\":\"2026-07-28T12:00:00Z\",\"lastUpdatedAt\":\"2026-07-28T12:00:01Z\",\"ttlMs\":null,\"inputRequests\":{{}}}}}}\n\ndata: {{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{{\"resultType\":\"complete\",\"_meta\":{{\"io.modelcontextprotocol/subscriptionId\":3}}}}}}\n\n"
);
write_http_cache_test_response(&mut watch, "text/event-stream", sse.as_bytes());
if notification_matches_handle {
let (mut update, _) = listener
.accept()
.expect("accept final task handle input submission");
let update_request = read_http_cache_test_request(&mut update);
assert_eq!(update_request["id"], 4);
assert_eq!(update_request["method"], "tasks/update");
assert_eq!(update_request["params"]["taskId"], "task-73");
assert_eq!(
update_request["params"]["inputResponses"],
serde_json::json!({})
);
write_http_cache_test_response(
&mut update,
"application/json",
br#"{"jsonrpc":"2.0","id":4,"result":{"resultType":"complete"}}"#,
);
let (mut poll, _) = listener.accept().expect("accept final task handle poll");
let poll_request = read_http_cache_test_request(&mut poll);
assert_eq!(poll_request["id"], 5);
assert_eq!(poll_request["method"], "tasks/get");
assert_eq!(poll_request["params"]["taskId"], "task-73");
write_http_cache_test_response(
&mut poll,
"application/json",
br#"{"jsonrpc":"2.0","id":5,"result":{"resultType":"complete","taskId":"task-73","status":"working","createdAt":"2026-07-28T12:00:00Z","lastUpdatedAt":"2026-07-28T12:00:02Z","ttlMs":null,"pollIntervalMs":25}}"#,
);
} else {
negative_done_rx
.recv_timeout(Duration::from_secs(1))
.expect("negative task watcher completes before post assertion");
listener
.set_nonblocking(true)
.expect("make negative task listener nonblocking");
match listener.accept() {
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
Ok(_) => panic!("foreign task notification must not trigger update or poll"),
Err(error) => panic!("observe negative task listener: {error}"),
}
}
});
let cx = Cx::for_request();
let mut client = http_test_runtime_block_on(HttpClient::connect(
&cx,
http_cache_test_plan(&modern_target),
ClientInfo {
name: "public-final-task-handle-client".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
))
.expect("public HTTP client completes Tasks discovery");
let mut handle = http_test_runtime_block_on(client.attach_final_task(
&cx,
FinalTaskId::parse("task-73").expect("bounded public task handle ID"),
))
.expect("public HTTP task attachment admits the first snapshot");
assert!(matches!(handle.task(), FinalTask::Working(_)));
assert_eq!(
handle
.task()
.base()
.poll_interval_ms
.as_ref()
.map(|value| value.as_str()),
Some("25")
);
let limits = sse::SseLimits::new(1_024, 8_192, 8).expect("bounded final task watch limits");
let mut watcher = http_test_runtime_block_on(handle.watch(&cx, &mut client, limits))
.expect("public HTTP task watcher opens");
assert!(matches!(
http_test_runtime_block_on(watcher.next_event(&cx))
.expect("task watcher acknowledgement is exact"),
Some(FinalTaskWatchEvent::Acknowledged { .. })
));
let notification = http_test_runtime_block_on(watcher.next_event(&cx));
let (task, error, next_id) = if notification_matches_handle {
assert!(matches!(
notification.expect("matching task status notification is admitted"),
Some(FinalTaskWatchEvent::TaskUpdated(
FinalTaskStatusNotification { .. }
))
));
assert!(matches!(
http_test_runtime_block_on(watcher.next_event(&cx))
.expect("matching task watch terminates normally"),
Some(FinalTaskWatchEvent::Terminal { .. })
));
drop(watcher);
assert!(matches!(handle.task(), FinalTask::InputRequired { .. }));
let acknowledgement =
http_test_runtime_block_on(handle.resume_input(&cx, &mut client, BTreeMap::new()))
.expect("input-required task resumes with its exact empty ledger");
assert!(acknowledgement.meta.is_none());
assert!(acknowledgement.additional.is_empty());
assert!(matches!(handle.task(), FinalTask::InputRequired { .. }));
http_test_runtime_block_on(handle.poll(&cx, &mut client))
.expect("poll reconciles the acknowledged task state");
(
handle.task().clone(),
None,
client.next_id.load(Ordering::Relaxed),
)
} else {
let error = notification
.expect_err("one changed task ID rejects the watch before state mutation");
drop(watcher);
let task = handle.task().clone();
let next_id = client.next_id.load(Ordering::Relaxed);
negative_done_tx
.send(())
.expect("release negative task listener assertion");
(task, Some(error), next_id)
};
server
.join()
.expect("public final task handle server joins");
(task, error, next_id)
}
#[cfg(unix)]
#[cfg(feature = "tasks")]
#[test]
fn public_http_final_task_handle_watches_resumes_and_polls() {
let (task, error, next_id) = run_public_http_final_task_handle_lifecycle("task-73");
assert!(error.is_none());
assert!(matches!(task, FinalTask::Working(_)));
assert_eq!(task.base().last_updated_at.as_str(), "2026-07-28T12:00:02Z");
assert_eq!(next_id, 6);
}
#[cfg(unix)]
#[cfg(feature = "tasks")]
#[test]
fn public_http_final_task_handle_rejects_one_foreign_task_notification() {
let (task, error, next_id) = run_public_http_final_task_handle_lifecycle("task-74");
assert!(matches!(
error,
Some(HttpClientError::Connection(
ClientHttpConnectionError::SubscriptionsListen(
http_executor::ModernHttpSubscriptionListenError::TaskEventOutsideAcceptedFilter
)
))
));
assert!(matches!(task, FinalTask::Working(_)));
assert_eq!(task.base().last_updated_at.as_str(), "2026-07-28T12:00:00Z");
assert_eq!(next_id, 4);
}
#[cfg(unix)]
#[derive(Clone, Copy)]
enum PublicFinalTaskCancellationEntryPoint {
Direct,
Handle,
}
#[cfg(unix)]
#[cfg(feature = "tasks")]
fn run_public_http_final_task_cancellation(
cancellation_task_id: &str,
entry_point: PublicFinalTaskCancellationEntryPoint,
) -> (FinalTask, serde_json::Value, FinalCancelTaskResult, u64) {
let listener =
TcpListener::bind("127.0.0.1:0").expect("bind public final task cancellation listener");
let address = listener
.local_addr()
.expect("read public final task cancellation listener address");
let modern_target = format!("http://{address}/mcp");
let cancellation_task_id = cancellation_task_id.to_owned();
let cancellation_task_id_for_server = cancellation_task_id.clone();
let server = std::thread::spawn(move || {
let discovery = modern_tasks_discovery_response(
"public-final-task-cancellation-server",
serde_json::json!({}),
);
let (mut probe, _) = listener
.accept()
.expect("accept final task cancellation discovery");
let probe_request = read_http_cache_test_request(&mut probe);
assert_eq!(probe_request["id"], 1);
assert_eq!(probe_request["method"], "server/discover");
write_http_cache_test_response(&mut probe, "application/json", discovery.as_bytes());
let (mut get, _) = listener
.accept()
.expect("accept final task cancellation attachment");
let get_request = read_http_cache_test_request(&mut get);
assert_eq!(get_request["id"], 2);
assert_eq!(get_request["method"], "tasks/get");
assert_eq!(get_request["params"]["taskId"], "task-73");
write_http_cache_test_response(
&mut get,
"application/json",
br#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","taskId":"task-73","status":"working","createdAt":"2026-07-28T12:00:00Z","lastUpdatedAt":"2026-07-28T12:00:00Z","ttlMs":null,"pollIntervalMs":25}}"#,
);
let (mut cancel, _) = listener
.accept()
.expect("accept final task cancellation request");
let cancel_request = read_http_cache_test_request(&mut cancel);
assert_eq!(cancel_request["id"], 3);
assert_eq!(cancel_request["method"], "tasks/cancel");
assert_eq!(
cancel_request["params"]["taskId"],
cancellation_task_id_for_server
);
assert_eq!(
cancel_request["params"]["_meta"]["io.modelcontextprotocol/protocolVersion"],
MODERN_PROTOCOL_VERSION
);
assert_eq!(
cancel_request["params"]["_meta"]["io.modelcontextprotocol/clientCapabilities"]["extensions"]
["io.modelcontextprotocol/tasks"],
serde_json::json!({})
);
write_http_cache_test_response(
&mut cancel,
"application/json",
br#"{"jsonrpc":"2.0","id":3,"result":{"resultType":"complete"}}"#,
);
});
let cx = Cx::for_request();
let mut client = http_test_runtime_block_on(HttpClient::connect(
&cx,
http_cache_test_plan(&modern_target),
ClientInfo {
name: "public-final-task-cancellation-client".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
))
.expect("public HTTP client completes Tasks discovery");
let handle = http_test_runtime_block_on(client.attach_final_task(
&cx,
FinalTaskId::parse("task-73").expect("bounded public task handle ID"),
))
.expect("public HTTP task attachment admits the first snapshot");
let expected_snapshot =
serde_json::to_value(handle.task()).expect("serialize owned task snapshot");
let acknowledgement = match entry_point {
PublicFinalTaskCancellationEntryPoint::Direct => http_test_runtime_block_on(
client.cancel_final_task(
&cx,
FinalTaskId::parse(cancellation_task_id)
.expect("bounded public task cancellation ID"),
),
)
.expect("direct task cancellation accepts its explicit task ID"),
PublicFinalTaskCancellationEntryPoint::Handle => {
http_test_runtime_block_on(handle.cancel(&cx, &mut client))
.expect("task handle delegates cancellation for its exact owned task")
}
};
let task = handle.task().clone();
let next_id = client.next_id.load(Ordering::Relaxed);
server
.join()
.expect("public final task cancellation server joins");
(task, expected_snapshot, acknowledgement, next_id)
}
#[cfg(unix)]
#[cfg(feature = "tasks")]
#[test]
fn public_http_final_task_cancel_exact_id_leaves_owned_snapshot_unchanged() {
let (task, expected_snapshot, acknowledgement, next_id) =
run_public_http_final_task_cancellation(
"task-73",
PublicFinalTaskCancellationEntryPoint::Direct,
);
assert!(acknowledgement.meta.is_none());
assert!(acknowledgement.additional.is_empty());
assert!(matches!(task, FinalTask::Working(_)));
assert_eq!(task.base().task_id.as_str(), "task-73");
assert_eq!(task.base().last_updated_at.as_str(), "2026-07-28T12:00:00Z");
assert_eq!(
serde_json::to_value(&task).expect("serialize retained task snapshot"),
expected_snapshot
);
assert_eq!(next_id, 4);
}
#[cfg(unix)]
#[cfg(feature = "tasks")]
#[test]
fn public_http_final_task_cancel_foreign_id_leaves_owned_snapshot_unchanged() {
// This differs from the direct positive case only in the explicit cancellation task ID.
let (task, expected_snapshot, acknowledgement, next_id) =
run_public_http_final_task_cancellation(
"task-74",
PublicFinalTaskCancellationEntryPoint::Direct,
);
assert!(acknowledgement.meta.is_none());
assert!(acknowledgement.additional.is_empty());
assert!(matches!(task, FinalTask::Working(_)));
assert_eq!(task.base().task_id.as_str(), "task-73");
assert_eq!(task.base().last_updated_at.as_str(), "2026-07-28T12:00:00Z");
assert_eq!(
serde_json::to_value(&task).expect("serialize retained task snapshot"),
expected_snapshot
);
assert_eq!(next_id, 4);
}
#[cfg(unix)]
#[cfg(feature = "tasks")]
#[test]
fn public_http_final_task_handle_cancel_delegates_without_snapshot_mutation() {
let (task, expected_snapshot, acknowledgement, next_id) =
run_public_http_final_task_cancellation(
"task-73",
PublicFinalTaskCancellationEntryPoint::Handle,
);
assert!(acknowledgement.meta.is_none());
assert!(acknowledgement.additional.is_empty());
assert!(matches!(task, FinalTask::Working(_)));
assert_eq!(task.base().task_id.as_str(), "task-73");
assert_eq!(
serde_json::to_value(&task).expect("serialize retained task snapshot"),
expected_snapshot
);
assert_eq!(next_id, 4);
}
#[cfg(unix)]
#[test]
fn cache_03_http_ttl_receipt_survives_later_result_routing() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind HTTP receipt listener");
let address = listener
.local_addr()
.expect("read HTTP receipt listener address");
let modern_target = format!("http://{address}/mcp");
let discovery =
modern_discovery_response("http-ttl-receipt-modern-server", &[MODERN_PROTOCOL_VERSION]);
let list_response = http_tools_list_response(2, "receipt", 1);
let server = std::thread::spawn(move || {
let (mut probe, _) = listener.accept().expect("accept HTTP receipt discovery");
let probe_request = read_http_cache_test_request(&mut probe);
assert_eq!(probe_request["id"], 1);
assert_eq!(probe_request["method"], "server/discover");
write_http_cache_test_response(&mut probe, "application/json", discovery.as_bytes());
let (mut list, _) = listener.accept().expect("accept HTTP receipt tools/list");
let list_request = read_http_cache_test_request(&mut list);
assert_eq!(list_request["id"], 2);
assert_eq!(list_request["method"], "tools/list");
write_http_cache_test_response(&mut list, "application/json", &list_response);
});
let cx = Cx::for_request();
let mut connection = http_test_runtime_block_on(ClientHttpConnection::connect(
&cx,
http_cache_test_plan(&modern_target),
ClientInfo {
name: "http-ttl-receipt-client".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
))
.expect("public HTTP connection completes discovery");
let (response, result_source, receipt, _, _) =
http_test_runtime_block_on(connection.request_json_with_result_source_at(
&cx,
"tools/list",
serde_json::json!({}),
RequestId::Number(2),
4_096,
))
.expect("HTTP response retains its transport decode receipt");
server.join().expect("HTTP receipt server must join");
// This is deliberately after receipt capture: it represents result
// routing work that must not extend a one-millisecond peer TTL.
std::thread::sleep(Duration::from_millis(2));
let core_parameters = serde_json::json!({
"_meta": FinalRequestMeta::new(ClientCapabilities::default())
});
let core_request = CoreRequest::decode(
ProtocolEra::Modern2026,
"tools/list",
Some(&core_parameters),
)
.expect("modern tools/list request admits its final metadata");
let (result, diagnostic) = decode_core_result_with_cache_ttl_from_source(
&core_request,
response
.result
.as_ref()
.expect("HTTP response has a result"),
result_source.as_deref(),
)
.expect("later typed-result routing succeeds");
assert!(diagnostic.is_none());
let key = FinalCacheKey::new(
"http-ttl-receipt-test",
MODERN_PROTOCOL_VERSION,
"{}",
"{}",
"tools/list",
"{}",
None,
1,
1,
1,
1,
CachePartitionKey::new("http-ttl-receipt-test"),
FinalCacheResultSet::Tools,
);
let mut cache = FinalResultCache::default();
let generation = cache.begin_fetch(key.result_set());
assert_eq!(
cache.insert_if_current_at(key.clone(), generation, result, receipt),
FinalCacheInsert::Stored
);
assert!(matches!(
cache.lookup_at(&key, Instant::now()),
FinalCacheLookup::Miss(FinalCacheMiss::Stale)
));
}
#[cfg(target_os = "linux")]
fn spawn_long_running_child() -> (Child, ChildStdout, ChildStdin, u32) {
let mut command = Command::new("sleep");
command
.arg("60")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = command.spawn().expect("spawn long-running child");
let pid = child.id();
let stdin = child.stdin.take().expect("child stdin");
let stdout = child.stdout.take().expect("child stdout");
(child, stdout, stdin, pid)
}
#[cfg(target_os = "linux")]
fn wait_for_process_exit(pid: u32) {
let process = std::path::PathBuf::from(format!("/proc/{pid}"));
let deadline = Instant::now() + Duration::from_secs(5);
while process.exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(25));
}
assert!(
!process.exists(),
"direct child process {pid} survived client cleanup"
);
}
#[cfg(target_os = "linux")]
#[test]
fn reality_check_regression_linux_proc_stat_parser_uses_final_command_delimiter() {
assert_eq!(
linux_process_state_group_and_thread_count(
b"123 (worker) with ) delimiters) S 45 678 6 7 8 9 10 11 12 13 14 15 16 17 18 19 4"
),
Some(('S', 678, 4))
);
assert_eq!(
linux_process_state_group_and_thread_count(b"malformed"),
None
);
let mut non_utf8_name = b"123 (worker-".to_vec();
non_utf8_name.push(0xff);
non_utf8_name.extend_from_slice(b") S 45 678 6 7 8 9 10 11 12 13 14 15 16 17 18 19 4");
assert_eq!(
linux_process_state_group_and_thread_count(&non_utf8_name),
Some(('S', 678, 4))
);
assert_eq!(linux_proc_stat_process_id(&non_utf8_name), Some(123));
}
#[cfg(target_os = "linux")]
#[test]
fn reality_check_regression_linux_status_requires_one_pid_namespace() {
assert!(linux_status_has_single_current_namespace_pid(
b"Name:\tworker\nNSpid:\t123\n",
123
));
assert!(!linux_status_has_single_current_namespace_pid(
b"Name:\tworker\nNSpid:\t1\t123\n",
123
));
assert!(!linux_status_has_single_current_namespace_pid(
b"Name:\tworker\nNSpid:\t123\t123\n",
123
));
assert!(!linux_status_has_single_current_namespace_pid(
b"NSpid:\t123\nNSpid:\t123\n",
123
));
assert!(!linux_status_has_single_current_namespace_pid(
b"Name:\tworker\n",
123
));
}
#[cfg(target_os = "linux")]
#[test]
fn reality_check_regression_linux_process_liveness_excludes_only_terminal_states() {
for state in ['R', 'S', 'D', 'T', 't', 'I'] {
assert!(linux_process_state_is_live(state), "state {state}");
assert!(!linux_process_stat_proves_single_terminal_task(state, 1));
}
for state in ['Z', 'X', 'x'] {
assert!(!linux_process_state_is_live(state), "state {state}");
assert!(linux_process_stat_proves_single_terminal_task(state, 1));
assert!(!linux_process_stat_proves_single_terminal_task(state, 0));
assert!(!linux_process_stat_proves_single_terminal_task(state, 2));
}
}
#[cfg(target_os = "linux")]
#[test]
fn reality_check_regression_linux_proc_scan_accepts_only_disappearance_errors() {
assert!(linux_proc_process_disappeared(&std::io::Error::from(
std::io::ErrorKind::NotFound
)));
assert!(linux_proc_process_disappeared(
&std::io::Error::from_raw_os_error(rustix::io::Errno::SRCH.raw_os_error())
));
assert!(!linux_proc_process_disappeared(&std::io::Error::from(
std::io::ErrorKind::PermissionDenied
)));
}
#[cfg(target_os = "linux")]
#[test]
fn reality_check_regression_linux_proc_mount_policy_requires_unrestricted_view() {
assert!(linux_proc_mounts_allow_complete_process_view(
"proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0\n"
));
assert!(linux_proc_mounts_allow_complete_process_view(
"proc /proc proc rw,hidepid=0 0 0\n"
));
assert!(linux_proc_mounts_allow_complete_process_view(
"proc /proc proc rw,hidepid=0,subset=pid 0 0\n"
));
assert!(!linux_proc_mounts_allow_complete_process_view(
"proc /proc proc rw,hidepid=2 0 0\n"
));
assert!(!linux_proc_mounts_allow_complete_process_view(
"proc /proc proc rw 0 0\nproc /proc proc rw 0 0\n"
));
assert!(!linux_proc_mounts_allow_complete_process_view(
"tmpfs /proc tmpfs rw 0 0\n"
));
assert!(!linux_proc_mounts_allow_complete_process_view(""));
}
#[cfg(target_os = "linux")]
#[test]
fn reality_check_regression_linux_group_scanner_rejects_invalid_id_and_deadline() {
assert!(
linux_process_group_has_live_member(0, Instant::now() + Duration::from_secs(1))
.is_err()
);
assert!(
linux_process_group_has_live_member(-1, Instant::now() + Duration::from_secs(1))
.is_err()
);
assert!(linux_process_group_has_live_member(1, Instant::now()).is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn reality_check_regression_linux_group_scanner_observes_live_member() {
let child = Command::new("/bin/sh")
.args(["-c", "exec /bin/sleep 60"])
.process_group(0)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn live process-group member");
let mut guard = ChildGuard::new(child);
let process_group_id =
i32::try_from(guard.child_mut().id()).expect("PID fits process-group range");
let observed = linux_process_group_has_live_member(
process_group_id,
Instant::now() + Duration::from_secs(2),
);
let cleanup = guard.cleanup();
assert!(observed.expect("complete live-group procfs scan"));
assert!(cleanup.is_ok(), "clean up live scan fixture: {cleanup:?}");
}
#[cfg(target_os = "linux")]
#[test]
fn reality_check_regression_linux_group_scanner_distinguishes_zombie_from_absence() {
let child = Command::new("/bin/sh")
.args(["-c", "exit 0"])
.process_group(0)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn zombie-only process-group fixture");
let mut guard = ChildGuard::new(child);
let process_group_id =
i32::try_from(guard.child_mut().id()).expect("PID fits process-group range");
let zombie_deadline = Instant::now() + Duration::from_secs(2);
let observed_zombie = loop {
let state = std::fs::read(format!("/proc/{process_group_id}/stat"))
.ok()
.and_then(|stat| linux_process_state_group_and_thread_count(&stat))
.map(|(state, _, _)| state);
if state.is_some_and(|state| matches!(state, 'Z' | 'X' | 'x')) {
break true;
}
if Instant::now() >= zombie_deadline {
break false;
}
std::thread::sleep(Duration::from_millis(10));
};
let observed = linux_process_group_has_live_member(
process_group_id,
Instant::now() + Duration::from_secs(2),
);
let process_group = rustix::process::Pid::from_raw(process_group_id)
.expect("positive process-group identifier");
let strict_absence = require_owned_process_group_absent(process_group);
let cleanup = guard.cleanup();
assert!(
observed_zombie,
"fixture must reach zombie state before inspection"
);
assert!(!observed.expect("complete zombie-only procfs scan"));
assert!(
strict_absence.is_err(),
"zombie-only observation must not weaken the identity-lost path"
);
assert!(
cleanup.is_ok(),
"reap zombie-only scan fixture: {cleanup:?}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn reality_check_regression_anchored_cleanup_accepts_zombie_only_descendant_group() {
let anchor = ProcessGroupAnchor::spawn().expect("spawn process-group anchor");
let process_group_id = anchor.raw_process_group();
let peer = Command::new("/bin/sh")
.args(["-c", "exec /bin/sleep 60"])
.process_group(process_group_id)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn anchored peer");
let group_guard = ChildGuard::with_process_group(peer, anchor);
let retained_descendant = Command::new("/bin/sh")
.args(["-c", "exit 0"])
.process_group(process_group_id)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn retained descendant fixture");
let retained_guard = ChildGuard::new(retained_descendant);
let cleanup = group_guard.cleanup();
let descendant_cleanup = retained_guard.cleanup();
assert!(
cleanup.is_ok(),
"zombie-only orphan must not fail cleanup: {cleanup:?}"
);
assert!(
descendant_cleanup.is_ok(),
"reap retained descendant fixture: {descendant_cleanup:?}"
);
}
fn make_closed_client_with_cx(initialized: bool, cx: Cx) -> Client {
let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string());
let mut command = Command::new(rustc);
command
.arg("--version")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = command.spawn().expect("spawn rustc --version");
let stdin = child.stdin.take().expect("child stdin");
let stdout = child.stdout.take().expect("child stdout");
let transport = StdioTransport::new(stdout, stdin);
let session = ClientSession::try_new(
ClientInfo {
name: "test-client".to_string(),
version: "0.1.0".to_string(),
},
ClientCapabilities::default(),
ServerInfo {
name: "test-server".to_string(),
version: "1.0.0".to_string(),
},
ServerCapabilities::default(),
PROTOCOL_VERSION.to_string(),
)
.expect("test client uses the exact supported protocol version");
if initialized {
Client::from_parts(
child,
transport,
cx,
session,
RequestTimeoutPolicy::new(Duration::from_millis(100), Duration::from_millis(100))
.unwrap(),
)
} else {
Client::from_parts_uninitialized(
child,
transport,
cx,
session,
RequestTimeoutPolicy::new(Duration::from_millis(100), Duration::from_millis(100))
.unwrap(),
)
}
}
fn make_closed_client(initialized: bool) -> Client {
make_closed_client_with_cx(initialized, Cx::for_request())
}
#[test]
fn internal_client_constructors_own_the_shared_response_sender() {
let mut initialized = make_closed_client(true);
assert!(initialized.is_initialized());
assert!(initialized.response_sender.lock().is_ok());
initialized
.close()
.expect("initialized constructor cleanup");
let mut uninitialized = make_closed_client(false);
assert!(!uninitialized.is_initialized());
assert!(uninitialized.response_sender.lock().is_ok());
uninitialized
.close()
.expect("uninitialized constructor cleanup");
}
#[cfg(all(unix, feature = "legacy-2024-11-05"))]
fn make_shell_scripted_initialized_client(script: &str, timeout: Duration) -> Client {
make_shell_scripted_initialized_client_for_version(script, timeout, PROTOCOL_VERSION)
}
#[cfg(all(unix, feature = "legacy-2024-11-05"))]
fn make_shell_scripted_initialized_client_with_reverse_handlers(
script: &str,
timeout: Duration,
handlers: ReverseRequestHandlers,
) -> Client {
let mut command = Command::new("sh");
command
.args(["-c", script])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = command.spawn().expect("spawn scripted peer");
let stdin = child.stdin.take().expect("scripted peer stdin");
let stdout = child.stdout.take().expect("scripted peer stdout");
let transport = StdioTransport::new(stdout, stdin);
let mut capabilities = ClientCapabilities::default();
handlers.derive_legacy_capabilities(&mut capabilities);
handlers
.validate_legacy_capabilities(&capabilities)
.expect("scripted legacy callbacks retain their advertised capability contract");
let session = ClientSession::try_new(
ClientInfo {
name: "test-client".to_string(),
version: "0.1.0".to_string(),
},
capabilities,
ServerInfo {
name: "scripted-server".to_string(),
version: "1.0.0".to_string(),
},
ServerCapabilities::default(),
PROTOCOL_VERSION.to_string(),
)
.expect("test client uses the exact legacy protocol version");
let runtime_cx = block_on(async {
Cx::current().expect("reverse callback tests require a runtime-bound context")
});
let mut client = Client::from_parts(
child,
transport,
runtime_cx,
session,
RequestTimeoutPolicy::new(timeout, timeout).unwrap(),
);
client.reverse_request_handlers = handlers;
client
}
#[cfg(unix)]
fn make_shell_scripted_initialized_client_for_version(
script: &str,
timeout: Duration,
protocol_version: &str,
) -> Client {
let mut command = Command::new("sh");
command
.args(["-c", script])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = command.spawn().expect("spawn scripted peer");
let stdin = child.stdin.take().expect("scripted peer stdin");
let stdout = child.stdout.take().expect("scripted peer stdout");
let transport = StdioTransport::new(stdout, stdin);
let session = ClientSession::try_new(
ClientInfo {
name: "test-client".to_string(),
version: "0.1.0".to_string(),
},
ClientCapabilities::default(),
ServerInfo {
name: "scripted-server".to_string(),
version: "1.0.0".to_string(),
},
ServerCapabilities::default(),
protocol_version.to_string(),
)
.expect("test client uses a supported protocol version");
Client::from_parts(
child,
transport,
Cx::for_request(),
session,
RequestTimeoutPolicy::new(timeout, timeout).unwrap(),
)
}
#[cfg(feature = "apps")]
fn install_test_mcp_apps_activation(client: &mut Client) {
use fastmcp_protocol::extensions::{
ClientExtensionDiscovery, ExtensionDescriptorRegistry, ExtensionLocalEnablement,
ExtensionSettings, ServerExtensionDiscovery, official_mcp_apps_empty_server_settings,
official_mcp_apps_negotiation_resolver, register_official_mcp_apps_extension,
};
let mut registry = ExtensionDescriptorRegistry::new();
let id = register_official_mcp_apps_extension(&mut registry)
.expect("the official Apps extension registers for the real-policy test");
registry
.freeze()
.expect("the real-policy test freezes its Apps registry");
let client_discovery = ClientExtensionDiscovery {
extensions: BTreeMap::from([(
id.clone(),
ExtensionSettings::new(serde_json::json!({
"mimeTypes": [fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE]
}))
.expect("the real-policy test uses admitted Apps client settings"),
)]),
};
let server_discovery = ServerExtensionDiscovery {
extensions: BTreeMap::from([(id.clone(), official_mcp_apps_empty_server_settings())]),
};
let mut local = ExtensionLocalEnablement::default();
local.enable(id);
let mut resolver = official_mcp_apps_negotiation_resolver();
let receipt = registry
.negotiate(
ProtocolEra::Modern2026,
&local,
&client_discovery,
&server_discovery,
&mut resolver,
)
.expect("the real-policy test negotiates MCP Apps bilaterally")
.mcp_apps_activation_receipt(®istry);
client.session.set_mcp_apps_activation_receipt(receipt);
assert!(
client.mcp_apps_active(),
"the public wire-host constructor must receive its retained bilateral receipt"
);
}
#[cfg(all(unix, feature = "apps"))]
#[test]
fn public_mcp_apps_stdio_policy_cancellation_reaches_its_real_upstream_request() {
let script = "IFS= read -r request; \\
case \"$request\" in *'\"method\":\"tools/call\"'*'\"id\":2'*) request_ok=true;; *) request_ok=false;; esac; \\
IFS= read -r cancellation; \\
case \"$cancellation\" in *'\"method\":\"notifications/cancelled\"'*'\"requestId\":2'*) cancellation_ok=true;; *) cancellation_ok=false;; esac; \\
IFS= read -r ping; \\
case \"$ping\" in *'\"method\":\"ping\"'*'\"id\":3'*) ping_ok=true;; *) ping_ok=false;; esac; \\
printf '{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{\"request\":%s,\"cancellation\":%s,\"ping\":%s}}\\n' \\
\"$request_ok\" \"$cancellation_ok\" \"$ping_ok\"; exec sleep 2";
let mut client = make_shell_scripted_initialized_client_for_version(
script,
Duration::from_secs(2),
MODERN_PROTOCOL_VERSION,
);
install_test_mcp_apps_activation(&mut client);
let cx = Cx::for_testing();
let (transport, mut view) = mcp_apps::mcp_apps_in_memory_wire_pair(8);
let configuration = mcp_apps::McpAppsWireHostConfiguration {
host_info: fastmcp_protocol::McpAppsBridgeImplementation {
name: "stdio-real-policy-host".to_owned(),
version: "1.0.0".to_owned(),
},
host_capabilities: fastmcp_protocol::McpAppsPinnedHostCapabilities::default(),
host_context: fastmcp_protocol::McpAppsPinnedHostContext::default(),
};
let mut host = client
.mcp_apps_wire_host(transport, configuration)
.expect("the public stdio Apps policy admits its negotiated Host");
block_on(async {
view.send_to_host(
&cx,
serde_json::json!({
"jsonrpc": "2.0",
"id": "initialize",
"method": "ui/initialize",
"params": {
"appInfo": {"name": "view", "version": "1"},
"appCapabilities": {},
"protocolVersion": fastmcp_protocol::MCP_APPS_HOST_VIEW_PROTOCOL_VERSION,
},
})
.to_string(),
)
.await
.expect("the real stdio policy receives the public initialize frame");
host.process_next(&cx)
.await
.expect("the real stdio policy commits initialize");
let _ = view
.receive_from_host(&cx)
.await
.expect("the View receives the public initialize response");
view.send_to_host(
&cx,
r#"{"jsonrpc":"2.0","method":"ui/notifications/initialized"}"#.to_owned(),
)
.await
.expect("the View activates the real stdio policy");
host.process_next(&cx)
.await
.expect("the real stdio policy becomes active");
view.send_to_host(
&cx,
r#"{"jsonrpc":"2.0","id":"view-call","method":"tools/call","params":{"name":"weather","arguments":{}}}"#.to_owned(),
)
.await
.expect("the View commits the real forwarded request");
view.send_to_host(
&cx,
r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":"view-call"}}"#.to_owned(),
)
.await
.expect("the View commits the matching real cancellation");
host.process_next(&cx)
.await
.expect("the real stdio policy settles its cancelled request");
});
drop(host);
let evidence: serde_json::Value = client
.send_request("ping", serde_json::json!({}))
.expect("the stdio client remains usable after the Apps-owned cancellation");
assert_eq!(
evidence,
serde_json::json!({"request": true, "cancellation": true, "ping": true}),
"the real subprocess saw the Apps request, its exact cancellation, and one later request"
);
client.close().expect("real stdio Apps policy cleanup");
}
#[cfg(all(unix, feature = "apps"))]
#[test]
fn public_mcp_apps_stdio_policy_wrong_id_cancellation_emits_no_cancel_frame() {
let script = "IFS= read -r request; \\
case \"$request\" in *'\"method\":\"tools/call\"'*'\"id\":2'*) request_ok=true;; *) request_ok=false;; esac; \\
printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"resultType\":\"complete\",\"content\":[],\"isError\":false}}'; \\
IFS= read -r follow_up; \\
case \"$follow_up\" in *'\"method\":\"notifications/cancelled\"'*) no_cancellation=false; ping_ok=false;; *'\"method\":\"ping\"'*'\"id\":3'*) no_cancellation=true; ping_ok=true;; *) no_cancellation=true; ping_ok=false;; esac; \\
printf '{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{\"request\":%s,\"noCancellation\":%s,\"ping\":%s}}\\n' \\
\"$request_ok\" \"$no_cancellation\" \"$ping_ok\"; exec sleep 2";
let mut client = make_shell_scripted_initialized_client_for_version(
script,
Duration::from_secs(2),
MODERN_PROTOCOL_VERSION,
);
install_test_mcp_apps_activation(&mut client);
let cx = Cx::for_testing();
let (transport, mut view) = mcp_apps::mcp_apps_in_memory_wire_pair(8);
let configuration = mcp_apps::McpAppsWireHostConfiguration {
host_info: fastmcp_protocol::McpAppsBridgeImplementation {
name: "stdio-real-policy-wrong-id-host".to_owned(),
version: "1.0.0".to_owned(),
},
host_capabilities: fastmcp_protocol::McpAppsPinnedHostCapabilities::default(),
host_context: fastmcp_protocol::McpAppsPinnedHostContext::default(),
};
let mut host = client
.mcp_apps_wire_host(transport, configuration)
.expect("the public stdio Apps policy admits its negotiated Host");
block_on(async {
view.send_to_host(
&cx,
serde_json::json!({
"jsonrpc": "2.0",
"id": "initialize",
"method": "ui/initialize",
"params": {
"appInfo": {"name": "view", "version": "1"},
"appCapabilities": {},
"protocolVersion": fastmcp_protocol::MCP_APPS_HOST_VIEW_PROTOCOL_VERSION,
},
})
.to_string(),
)
.await
.expect("the real stdio policy receives the public initialize frame");
host.process_next(&cx)
.await
.expect("the real stdio policy commits initialize");
let _ = view
.receive_from_host(&cx)
.await
.expect("the View receives the public initialize response");
view.send_to_host(
&cx,
r#"{"jsonrpc":"2.0","method":"ui/notifications/initialized"}"#.to_owned(),
)
.await
.expect("the View activates the real stdio policy");
host.process_next(&cx)
.await
.expect("the real stdio policy becomes active");
view.send_to_host(
&cx,
r#"{"jsonrpc":"2.0","id":"view-call","method":"tools/call","params":{"name":"weather","arguments":{}}}"#.to_owned(),
)
.await
.expect("the View commits the real forwarded request");
view.send_to_host(
&cx,
r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":"other-call"}}"#.to_owned(),
)
.await
.expect("the View commits the one-field wrong cancellation");
let first_result = host.process_next(&cx).await;
match first_result {
Err(mcp_apps::McpAppsHostError::Bridge(
fastmcp_protocol::McpAppsBridgeError::UnknownCorrelation,
)) => {}
Ok(()) => match host.process_next(&cx).await {
Err(mcp_apps::McpAppsHostError::Bridge(
fastmcp_protocol::McpAppsBridgeError::UnknownCorrelation,
)) => {}
result => panic!(
"the deferred one-variable wrong cancellation must yield UnknownCorrelation; result={result:?}"
),
},
result => panic!(
"the one-variable wrong cancellation must yield UnknownCorrelation; result={result:?}"
),
}
let response: serde_json::Value = serde_json::from_str(
&view
.receive_from_host(&cx)
.await
.expect("the live request returns one normal View response"),
)
.expect("the normal View response is JSON-RPC");
assert_eq!(response["id"], "view-call");
assert_eq!(response["result"]["content"], serde_json::json!([]));
});
drop(host);
let evidence: serde_json::Value = client
.send_request("ping", serde_json::json!({}))
.expect("the stdio client remains usable after the inert Apps cancellation");
assert_eq!(
evidence,
serde_json::json!({"request": true, "noCancellation": true, "ping": true}),
"a wrong View ID must not emit an upstream stdio cancellation frame"
);
client.close().expect("real stdio Apps wrong-ID cleanup");
}
#[cfg(all(unix, feature = "apps"))]
#[test]
fn public_mcp_apps_http_policy_cancellation_drops_the_real_request_before_response_headers() {
let listener = TcpListener::bind("127.0.0.1:0")
.expect("bind the real Apps HTTP cancellation listener");
let address = listener
.local_addr()
.expect("read the real Apps HTTP cancellation address");
let (post_accepted_sender, post_accepted_receiver) = std::sync::mpsc::sync_channel(1);
let (request_dropped_sender, request_dropped_receiver) = std::sync::mpsc::sync_channel(1);
let server = std::thread::spawn(move || {
let (mut discovery, _) = listener
.accept()
.expect("accept the real Apps HTTP discovery request");
let discovery_request = read_http_cache_test_request(&mut discovery);
assert_eq!(discovery_request["id"], 1);
assert_eq!(discovery_request["method"], "server/discover");
let capabilities = fastmcp_protocol::ServerDiscoverCapabilities::from_registry(
&fastmcp_protocol::ServerBehaviorRegistry::default(),
BTreeMap::from([(
fastmcp_protocol::extensions::OFFICIAL_MCP_APPS_EXTENSION_ID.to_owned(),
serde_json::json!({}),
)]),
)
.expect("the real Apps HTTP discovery declares valid server settings");
let discovery_result = ServerDiscoverResult::new(
capabilities,
ServerInfo {
name: "apps-http-cancellation-server".to_owned(),
version: "1.0.0".to_owned(),
},
None,
fastmcp_protocol::DiscoveryCacheHints::private_ttl_ms(0),
);
let mut discovery_response = serde_json::json!({
"jsonrpc": JSONRPC_VERSION,
"id": 1,
"result": discovery_result,
});
discovery_response["result"]["supportedVersions"] =
serde_json::json!([MODERN_PROTOCOL_VERSION]);
write_http_cache_test_response(
&mut discovery,
"application/json",
serde_json::to_string(&discovery_response)
.expect("the real Apps HTTP discovery response serializes")
.as_bytes(),
);
let (mut tool_call, _) = listener
.accept()
.expect("accept the real Apps HTTP tool request");
let tool_request = read_http_cache_test_request(&mut tool_call);
assert_eq!(tool_request["id"], 2);
assert_eq!(tool_request["method"], "tools/call");
post_accepted_sender
.send(())
.expect("allow the matching Apps cancellation after POST acceptance");
tool_call
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
.expect("bound the withheld-header close observation");
let mut probe = [0_u8; 1];
let dropped = match tool_call.read(&mut probe) {
Ok(0) => true,
Ok(_) => false,
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
) =>
{
false
}
Err(error) => panic!("observe the withheld-header request close: {error}"),
};
request_dropped_sender
.send(dropped)
.expect("report the real Apps request close before headers");
});
let cx = Cx::for_testing();
let target = format!("http://{address}/mcp");
let settings =
McpAppsClientSettings::new(vec![fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE.to_owned()])
.expect("the real Apps HTTP policy uses admitted client settings");
let mut client = http_test_runtime_block_on(HttpClient::connect_with_mcp_apps(
&cx,
http_cache_test_plan(&target),
ClientInfo {
name: "apps-http-cancellation-client".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
Some(settings),
ReverseRequestHandlers::new(),
))
.expect("the real HTTP client completes bilateral Apps discovery");
assert!(client.mcp_apps_active());
let (transport, mut view) = mcp_apps::mcp_apps_in_memory_wire_pair(8);
let configuration = mcp_apps::McpAppsWireHostConfiguration {
host_info: fastmcp_protocol::McpAppsBridgeImplementation {
name: "http-real-policy-host".to_owned(),
version: "1.0.0".to_owned(),
},
host_capabilities: fastmcp_protocol::McpAppsPinnedHostCapabilities::default(),
host_context: fastmcp_protocol::McpAppsPinnedHostContext::default(),
};
let mut host = client
.mcp_apps_wire_host(transport, configuration)
.expect("the public HTTP Apps policy admits its negotiated Host");
http_test_runtime_block_on(async {
view.send_to_host(
&cx,
serde_json::json!({
"jsonrpc": "2.0",
"id": "initialize",
"method": "ui/initialize",
"params": {
"appInfo": {"name": "view", "version": "1"},
"appCapabilities": {},
"protocolVersion": fastmcp_protocol::MCP_APPS_HOST_VIEW_PROTOCOL_VERSION,
},
})
.to_string(),
)
.await
.expect("the real HTTP policy receives the public initialize frame");
host.process_next(&cx)
.await
.expect("the real HTTP policy commits initialize");
let _ = view
.receive_from_host(&cx)
.await
.expect("the View receives the public HTTP initialize response");
view.send_to_host(
&cx,
r#"{"jsonrpc":"2.0","method":"ui/notifications/initialized"}"#.to_owned(),
)
.await
.expect("the View activates the real HTTP policy");
host.process_next(&cx)
.await
.expect("the real HTTP policy becomes active");
view.send_to_host(
&cx,
r#"{"jsonrpc":"2.0","id":"view-call","method":"tools/call","params":{"name":"weather","arguments":{}}}"#.to_owned(),
)
.await
.expect("the View commits the real HTTP forwarded request");
});
let cancellation_controller = std::thread::spawn(move || {
post_accepted_receiver
.recv_timeout(std::time::Duration::from_secs(2))
.expect("the peer accepts the real Apps POST before cancellation");
let cancellation_cx = Cx::for_testing();
http_test_runtime_block_on(async {
view.send_to_host(
&cancellation_cx,
r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":"view-call"}}"#.to_owned(),
)
.await
.expect("the View commits the matching real HTTP cancellation");
});
});
http_test_runtime_block_on(async {
host.process_next(&cx)
.await
.expect("the real HTTP policy settles its cancelled request");
});
cancellation_controller
.join()
.expect("matching Apps cancellation controller joins");
drop(host);
assert!(
request_dropped_receiver
.recv_timeout(Duration::from_secs(2))
.expect("the real HTTP peer observes the cancelled request close"),
"the matching Apps cancellation must drop its real HTTP request before headers"
);
server
.join()
.expect("the real Apps HTTP cancellation server joins");
}
#[cfg(all(unix, feature = "apps"))]
#[test]
fn public_mcp_apps_http_policy_wrong_id_cancellation_keeps_the_real_withheld_header_request_live()
{
let listener =
TcpListener::bind("127.0.0.1:0").expect("bind the real Apps HTTP wrong-ID listener");
let address = listener
.local_addr()
.expect("read the real Apps HTTP wrong-ID address");
let (post_accepted_sender, post_accepted_receiver) = std::sync::mpsc::sync_channel(1);
let (wrong_id_sent_sender, wrong_id_sent_receiver) = std::sync::mpsc::sync_channel(1);
let server = std::thread::spawn(move || {
let (mut discovery, _) = listener
.accept()
.expect("accept the real Apps HTTP discovery request");
let discovery_request = read_http_cache_test_request(&mut discovery);
assert_eq!(discovery_request["id"], 1);
assert_eq!(discovery_request["method"], "server/discover");
let capabilities = fastmcp_protocol::ServerDiscoverCapabilities::from_registry(
&fastmcp_protocol::ServerBehaviorRegistry::default(),
BTreeMap::from([(
fastmcp_protocol::extensions::OFFICIAL_MCP_APPS_EXTENSION_ID.to_owned(),
serde_json::json!({}),
)]),
)
.expect("the real Apps HTTP discovery declares valid server settings");
let discovery_result = ServerDiscoverResult::new(
capabilities,
ServerInfo {
name: "apps-http-wrong-id-server".to_owned(),
version: "1.0.0".to_owned(),
},
None,
fastmcp_protocol::DiscoveryCacheHints::private_ttl_ms(0),
);
let mut discovery_response = serde_json::json!({
"jsonrpc": JSONRPC_VERSION,
"id": 1,
"result": discovery_result,
});
discovery_response["result"]["supportedVersions"] =
serde_json::json!([MODERN_PROTOCOL_VERSION]);
write_http_cache_test_response(
&mut discovery,
"application/json",
serde_json::to_string(&discovery_response)
.expect("the real Apps HTTP discovery response serializes")
.as_bytes(),
);
let (mut tool_call, _) = listener
.accept()
.expect("accept the real Apps HTTP tool request");
let tool_request = read_http_cache_test_request(&mut tool_call);
assert_eq!(tool_request["id"], 2);
assert_eq!(tool_request["method"], "tools/call");
post_accepted_sender
.send(())
.expect("allow the one-variable wrong-ID cancellation after POST acceptance");
wrong_id_sent_receiver
.recv_timeout(std::time::Duration::from_secs(2))
.expect("wait for the wrong-ID cancellation while headers stay withheld");
tool_call
.set_read_timeout(Some(std::time::Duration::from_millis(100)))
.expect("bound the wrong-ID withheld-header liveness observation");
let mut probe = [0_u8; 1];
match tool_call.read(&mut probe) {
Ok(0) => panic!("a wrong Apps cancellation ID must not drop the real HTTP request"),
Ok(_) => {
panic!("the client must not send bytes while response headers are withheld")
}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
) => {}
Err(error) => panic!("observe wrong-ID withheld-header request liveness: {error}"),
}
let tool_response = serde_json::json!({
"jsonrpc": JSONRPC_VERSION,
"id": 2,
"result": {"resultType": "complete", "content": [], "isError": false},
});
write_http_cache_test_response(
&mut tool_call,
"application/json",
serde_json::to_string(&tool_response)
.expect("the real Apps HTTP normal body serializes")
.as_bytes(),
);
});
let cx = Cx::for_testing();
let target = format!("http://{address}/mcp");
let settings =
McpAppsClientSettings::new(vec![fastmcp_protocol::MCP_APPS_HTML_MIME_TYPE.to_owned()])
.expect("the real Apps HTTP policy uses admitted client settings");
let mut client = http_test_runtime_block_on(HttpClient::connect_with_mcp_apps(
&cx,
http_cache_test_plan(&target),
ClientInfo {
name: "apps-http-wrong-id-client".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
Some(settings),
ReverseRequestHandlers::new(),
))
.expect("the real HTTP client completes bilateral Apps discovery");
assert!(client.mcp_apps_active());
let (transport, mut view) = mcp_apps::mcp_apps_in_memory_wire_pair(8);
let configuration = mcp_apps::McpAppsWireHostConfiguration {
host_info: fastmcp_protocol::McpAppsBridgeImplementation {
name: "http-real-policy-wrong-id-host".to_owned(),
version: "1.0.0".to_owned(),
},
host_capabilities: fastmcp_protocol::McpAppsPinnedHostCapabilities::default(),
host_context: fastmcp_protocol::McpAppsPinnedHostContext::default(),
};
let mut host = client
.mcp_apps_wire_host(transport, configuration)
.expect("the public HTTP Apps policy admits its negotiated Host");
http_test_runtime_block_on(async {
view.send_to_host(
&cx,
serde_json::json!({
"jsonrpc": "2.0",
"id": "initialize",
"method": "ui/initialize",
"params": {
"appInfo": {"name": "view", "version": "1"},
"appCapabilities": {},
"protocolVersion": fastmcp_protocol::MCP_APPS_HOST_VIEW_PROTOCOL_VERSION,
},
})
.to_string(),
)
.await
.expect("the real HTTP policy receives the public initialize frame");
host.process_next(&cx)
.await
.expect("the real HTTP policy commits initialize");
let _ = view
.receive_from_host(&cx)
.await
.expect("the View receives the public HTTP initialize response");
view.send_to_host(
&cx,
r#"{"jsonrpc":"2.0","method":"ui/notifications/initialized"}"#.to_owned(),
)
.await
.expect("the View activates the real HTTP policy");
host.process_next(&cx)
.await
.expect("the real HTTP policy becomes active");
view.send_to_host(
&cx,
r#"{"jsonrpc":"2.0","id":"view-call","method":"tools/call","params":{"name":"weather","arguments":{}}}"#.to_owned(),
)
.await
.expect("the View commits the real HTTP forwarded request");
});
let wrong_id_controller = std::thread::spawn(move || {
post_accepted_receiver
.recv_timeout(std::time::Duration::from_secs(2))
.expect("the peer accepts the real Apps POST before the wrong-ID control");
let controller_cx = Cx::for_testing();
let response = http_test_runtime_block_on(async {
view.send_to_host(
&controller_cx,
r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":"other-call"}}"#.to_owned(),
)
.await
.expect("the View commits the one-variable wrong HTTP cancellation");
wrong_id_sent_sender.send(()).expect(
"allow the peer to release headers after proving the request stayed live",
);
view.receive_from_host(&controller_cx)
.await
.expect("the preserved HTTP request receives its normal View response")
});
let response: serde_json::Value =
serde_json::from_str(&response).expect("the preserved HTTP response is JSON-RPC");
assert_eq!(response["id"], "view-call");
assert_eq!(response["result"]["content"], serde_json::json!([]));
});
let result = http_test_runtime_block_on(async { host.process_next(&cx).await });
assert!(matches!(
result,
Err(mcp_apps::McpAppsHostError::Bridge(
fastmcp_protocol::McpAppsBridgeError::UnknownCorrelation
))
));
wrong_id_controller
.join()
.expect("wrong-ID Apps cancellation controller joins");
drop(host);
server
.join()
.expect("the real Apps HTTP wrong-ID server joins");
}
#[cfg(all(unix, feature = "legacy-2024-11-05"))]
fn make_scripted_initialized_client(response: JsonRpcMessage) -> Client {
let response_line = serde_json::to_string(&response).expect("serialize scripted response");
assert!(
!response_line.contains('\''),
"the shell fixture requires a single-quote-free JSON line"
);
// Keep the peer alive briefly so the client can write its request, but
// make the fixture self-terminating without an orphanable watchdog.
let script = format!("printf '%s\\n' '{response_line}'; exec sleep 2");
make_shell_scripted_initialized_client(&script, Duration::from_secs(1))
}
#[cfg(all(unix, feature = "legacy-2024-11-05"))]
fn make_peer_silent_past_deadline_client(response: JsonRpcMessage) -> Client {
let response_line = serde_json::to_string(&response).expect("serialize scripted response");
assert!(
!response_line.contains('\''),
"the shell fixture requires a single-quote-free JSON line"
);
// The delay is intentionally much larger than the five-millisecond
// request deadline. The peer remains bounded even if client cleanup
// regresses, and no background watchdog can outlive the fixture.
let script = format!("sleep 1; printf '%s\\n' '{response_line}'; exec sleep 2");
make_shell_scripted_initialized_client(&script, Duration::from_millis(5))
}
#[test]
fn request_timeout_policy_has_distinct_validated_bounds_and_named_reset() {
let default = RequestTimeoutPolicy::default();
assert_eq!(default.idle_timeout(), Duration::from_secs(30));
assert_eq!(default.absolute_timeout(), Duration::from_secs(120));
assert!(default.resets_idle_on_matching_progress());
for (idle, absolute) in [
(Duration::ZERO, Duration::from_millis(1)),
(Duration::from_nanos(999_999), Duration::from_millis(1)),
(
MAX_CLIENT_IDLE_TIMEOUT + Duration::from_nanos(1),
Duration::from_millis(1),
),
(Duration::from_millis(1), Duration::ZERO),
(Duration::from_millis(1), Duration::from_nanos(999_999)),
(
Duration::from_millis(1),
MAX_CLIENT_ABSOLUTE_TIMEOUT + Duration::from_nanos(1),
),
] {
assert!(RequestTimeoutPolicy::new(idle, absolute).is_err());
}
let strict =
RequestTimeoutPolicy::new(Duration::from_millis(1), MAX_CLIENT_ABSOLUTE_TIMEOUT)
.unwrap()
.reset_idle_on_matching_progress(false);
assert_eq!(strict.idle_timeout(), Duration::from_millis(1));
assert_eq!(strict.absolute_timeout(), MAX_CLIENT_ABSOLUTE_TIMEOUT);
assert!(!strict.resets_idle_on_matching_progress());
let exact_bounds =
RequestTimeoutPolicy::new(MAX_CLIENT_IDLE_TIMEOUT, Duration::from_millis(1))
.expect("the exact idle maximum and absolute minimum are valid");
assert_eq!(exact_bounds.idle_timeout(), MAX_CLIENT_IDLE_TIMEOUT);
assert_eq!(exact_bounds.absolute_timeout(), Duration::from_millis(1));
}
#[test]
fn request_deadline_idle_reset_never_moves_absolute() {
let committed_at = Instant::now();
let policy =
RequestTimeoutPolicy::new(Duration::from_millis(100), Duration::from_millis(250))
.unwrap();
let mut deadlines = RequestDeadlines::start_at(policy, committed_at).unwrap();
let absolute = deadlines.absolute;
deadlines
.reset_idle_at(committed_at + Duration::from_millis(80))
.unwrap();
assert_eq!(deadlines.idle, committed_at + Duration::from_millis(180));
assert_eq!(deadlines.absolute, absolute);
assert_eq!(
deadlines.expired_at(committed_at + Duration::from_millis(181)),
Some(RequestTimeoutSource::Idle)
);
let mut absolute_deadlines = RequestDeadlines::start_at(policy, committed_at).unwrap();
absolute_deadlines
.reset_idle_at(committed_at + Duration::from_millis(200))
.unwrap();
assert_eq!(absolute_deadlines.absolute, absolute);
assert_eq!(
absolute_deadlines.expired_at(committed_at + Duration::from_millis(250)),
Some(RequestTimeoutSource::Absolute)
);
}
#[test]
fn request_deadline_tie_selects_absolute_source() {
let committed_at = Instant::now();
let policy =
RequestTimeoutPolicy::new(Duration::from_millis(100), Duration::from_millis(100))
.unwrap();
let deadlines = RequestDeadlines::start_at(policy, committed_at).unwrap();
assert_eq!(deadlines.next_kind(), RequestTimeoutSource::Absolute);
assert_eq!(
deadlines.expired_at(committed_at + Duration::from_millis(99)),
None
);
assert_eq!(
deadlines.expired_at(committed_at + Duration::from_millis(100)),
Some(RequestTimeoutSource::Absolute)
);
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn invalid_response_timeout_is_rejected_before_request_commit() {
let mut client =
make_shell_scripted_initialized_client("exec sleep 2", Duration::from_millis(100));
client.timeout_policy = RequestTimeoutPolicy {
idle_timeout: Duration::ZERO,
absolute_timeout: Duration::from_secs(1),
reset_idle_on_matching_progress: true,
};
let result: McpResult<serde_json::Value> =
client.send_request("test/invalid-timeout", serde_json::json!({}));
let error = result.expect_err("invalid timeout must fail before request commitment");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(client.next_id.load(Ordering::SeqCst), 2);
let mut progress_events = Vec::new();
let mut on_progress = |progress: f64, total: Option<f64>, message: Option<&str>| {
progress_events.push((progress, total, message.map(ToOwned::to_owned)));
};
let progress_error = client
.call_tool_with_progress(
"test/invalid-timeout",
serde_json::json!({}),
&mut on_progress,
)
.expect_err("invalid timeout must fail before progress-token allocation");
assert_eq!(progress_error.code, McpErrorCode::InvalidParams);
assert_eq!(progress_events.len(), 0);
assert_eq!(client.next_id.load(Ordering::SeqCst), 2);
assert_eq!(client.responses.pending_len(), 0);
assert_eq!(client.responses.tombstone_len(), 0);
assert!(client.responses.terminal_error().is_none());
assert!(client.is_initialized());
assert!(client.child.is_some());
assert!(!client.transport_is_closed());
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn silent_peer_timeout_is_request_local() {
let response = JsonRpcMessage::Response(JsonRpcResponse::success(
RequestId::Number(2),
serde_json::json!({"late": true}),
));
let mut client = make_peer_silent_past_deadline_client(response);
let result: McpResult<serde_json::Value> =
client.send_request("test/late", serde_json::json!({}));
let error = result.expect_err("a silent peer must time out the request");
assert!(error.message.contains("timed out"));
assert!(client.is_initialized());
assert_eq!(client.responses.pending_len(), 0);
assert_eq!(client.responses.tombstone_len(), 1);
assert_eq!(client.responses.cancellation_control_len(), 1);
assert!(client.responses.terminal_error().is_none());
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn silent_peer_timeout_via_progress_api_is_request_local() {
let response = JsonRpcMessage::Response(JsonRpcResponse::success(
RequestId::Number(2),
serde_json::json!({"late": true}),
));
let mut client = make_peer_silent_past_deadline_client(response);
let marker = ProgressMarker::Number(JsonInteger::from(2));
let mut progress_events = Vec::new();
let mut callback = |progress: f64, total: Option<f64>, message: Option<&str>| {
progress_events.push((progress, total, message.map(ToOwned::to_owned)));
};
let result: McpResult<serde_json::Value> = client.send_request_with_progress(
"test/late-progress",
serde_json::json!({}),
2,
&marker,
&mut callback,
);
let error = result.expect_err("a silent peer must time out the progress request");
assert!(error.message.contains("timed out"));
assert_eq!(progress_events.len(), 0);
assert!(client.is_initialized());
assert_eq!(client.responses.pending_len(), 0);
assert_eq!(client.responses.tombstone_len(), 1);
assert_eq!(client.responses.cancellation_control_len(), 1);
assert!(client.responses.terminal_error().is_none());
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn public_cancellation_rejects_non_owned_id_without_peer_contact() {
let script = "IFS= read -r request; \
case \"$request\" in *'\"method\":\"test/new-generation\"'*'\"id\":2'*) \
printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"request\":true}}\\n' ;; *) exit 1 ;; esac; \
exec sleep 2";
let mut client = make_shell_scripted_initialized_client(script, Duration::from_secs(2));
let error = client
.cancel_request(2_i64, Some("pre-cancel".to_string()))
.expect_err("a peer-known but non-owned ID cannot produce a cancellation frame");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert_eq!(client.responses.cancellation_control_len(), 0);
assert_eq!(client.responses.pending_len(), 0);
assert_eq!(client.responses.tombstone_len(), 0);
let evidence: serde_json::Value = client
.send_request("test/new-generation", serde_json::json!({}))
.expect("the first peer contact is the ordinary local request");
assert_eq!(evidence, serde_json::json!({"request": true}));
assert_eq!(client.responses.cancellation_control_len(), 0);
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn request_scoped_cancellation_before_commit_makes_zero_stdio_contact() {
let script = "IFS= read -r request; \\
case \"$request\" in *'\"method\":\"test/after-pre-cancel\"'*'\"id\":2'*) \\
printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"onlyContact\":true}}\\n' ;; *) exit 1 ;; esac; \\
exec sleep 2";
let mut client = make_shell_scripted_initialized_client(script, Duration::from_secs(2));
let cancellation = McpRequestCancellation::new();
assert!(cancellation.cancel());
let committed = Cell::new(false);
let error = client
.request_core_with_cancellation(
&Cx::for_testing(),
&cancellation,
"tools/call",
serde_json::json!({"name": "never-contacted", "arguments": {}}),
|_| committed.set(true),
)
.expect_err("pre-commit cancellation must reject locally");
assert_eq!(error.code, McpErrorCode::RequestCancelled);
assert!(
!committed.get(),
"no upstream ID is committed before cancellation"
);
let evidence: serde_json::Value = client
.send_request("test/after-pre-cancel", serde_json::json!({}))
.expect("the next request is the scripted peer's first observed contact");
assert_eq!(evidence, serde_json::json!({"onlyContact": true}));
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn request_scoped_cancellation_after_commit_sends_stdio_control_for_exposed_id() {
let script = "IFS= read -r request; IFS= read -r cancellation; IFS= read -r after; \\
case \"$request\" in *'\"method\":\"tools/call\"'*'\"id\":2'*) request_ok=true;; *) request_ok=false;; esac; \\
case \"$cancellation\" in *'\"method\":\"notifications/cancelled\"'*'\"requestId\":2'*) cancellation_ok=true;; *) cancellation_ok=false;; esac; \\
case \"$after\" in *'\"method\":\"test/after-cancel\"'*'\"id\":3'*) after_ok=true;; *) after_ok=false;; esac; \\
printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"late\":true}}\\n'; \\
printf '{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{\"request\":%s,\"cancellation\":%s,\"after\":%s}}\\n' \\
\"$request_ok\" \"$cancellation_ok\" \"$after_ok\"; exec sleep 2";
let mut client = make_shell_scripted_initialized_client(script, Duration::from_secs(2));
let cancellation = McpRequestCancellation::new();
let committed_id = Cell::new(None::<i64>);
let error = client
.request_core_with_cancellation(
&Cx::for_testing(),
&cancellation,
"tools/call",
serde_json::json!({"name": "cancelled-after-commit", "arguments": {}}),
|request_id| {
committed_id.set(match request_id {
RequestId::Number(id) => Some(*id),
RequestId::String(_) | RequestId::Integer(_) => None,
});
assert!(cancellation.cancel());
},
)
.expect_err("post-commit cancellation must reject locally after its control frame");
assert_eq!(error.code, McpErrorCode::RequestCancelled);
assert_eq!(committed_id.get(), Some(2));
let evidence: serde_json::Value = client
.send_request("test/after-cancel", serde_json::json!({}))
.expect("the retired response is drained before the next result");
assert_eq!(
evidence,
serde_json::json!({"request": true, "cancellation": true, "after": true})
);
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn public_cancellation_tombstones_once_and_uses_bounded_control() {
let script = "IFS= read -r first; IFS= read -r cancellation; IFS= read -r second; \
case \"$first\" in *'\"id\":20'*) first_ok=true;; *) first_ok=false;; esac; \
case \"$cancellation\" in *'\"method\":\"notifications/cancelled\"'*) method_ok=true;; *) method_ok=false;; esac; \
case \"$cancellation\" in *'\"requestId\":20'*) id_ok=true;; *) id_ok=false;; esac; \
case \"$cancellation\" in *'\"reason\":\"stop\"'*) reason_ok=true;; *) reason_ok=false;; esac; \
case \"$cancellation\" in *'\"awaitCleanup\"'*) cleanup_ok=false;; *) cleanup_ok=true;; esac; \
if [ \"$method_ok\" = true ] && [ \"$id_ok\" = true ] && [ \"$reason_ok\" = true ] && [ \"$cleanup_ok\" = true ]; \
then cancellation_ok=true; else cancellation_ok=false; fi; \
case \"$second\" in *'\"id\":2'*) second_ok=true;; *) second_ok=false;; esac; \
printf '{\"jsonrpc\":\"2.0\",\"id\":20,\"result\":{\"late\":true}}\\n'; \
printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"first\":%s,\"cancellation\":%s,\"second\":%s}}\\n' \
\"$first_ok\" \"$cancellation_ok\" \"$second_ok\"; exec sleep 2";
let mut client = make_shell_scripted_initialized_client(script, Duration::from_secs(2));
let request_id = RequestId::Number(20);
let request = JsonRpcRequest::new("test/cancel", Some(serde_json::json!({})), 20);
let mut waiter = client
.responses
.register(request_id.clone())
.expect("register cancellation owner");
client
.send_to_server(&JsonRpcMessage::Request(request))
.expect("commit request before public cancellation");
client
.cancel_request(request_id.clone(), Some("stop".to_string()))
.expect("first public cancellation must commit one control frame");
let duplicate = client
.cancel_request(request_id, Some("duplicate".to_string()))
.expect_err("a retired request is no longer a live cancellation owner");
assert_eq!(duplicate.code, McpErrorCode::InvalidRequest);
let waiter_error = waiter
.try_response()
.expect_err("the request owner receives local cancellation");
assert_eq!(waiter_error.code, McpErrorCode::RequestCancelled);
assert_eq!(client.responses.pending_len(), 0);
assert_eq!(client.responses.tombstone_len(), 1);
assert_eq!(client.responses.cancellation_control_len(), 1);
let evidence: serde_json::Value = client
.send_request("test/after-cancel", serde_json::json!({}))
.expect("late response retires the tombstone without misalignment");
assert_eq!(
evidence,
serde_json::json!({
"first": true,
"cancellation": true,
"second": true
})
);
assert_eq!(client.responses.tombstone_len(), 0);
assert!(client.responses.terminal_error().is_none());
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[test]
fn modern_stdio_cancellation_uses_only_exact_cancellation_members() {
let discovery =
modern_discovery_response("modern-cancellation-server", &[MODERN_PROTOCOL_VERSION]);
let script = format!(
"IFS= read -r discovery || exit 1; \
case \"$discovery\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery}' ;; *) exit 1 ;; esac; \
IFS= read -r first || exit 1; \
case \"$first\" in *'\"method\":\"test/cancel\"'*'\"id\":20'*) ;; *) exit 1 ;; esac; \
IFS= read -r cancellation || exit 1; \
case \"$cancellation\" in *'\"method\":\"notifications/cancelled\"'*) ;; *) exit 1 ;; esac; \
case \"$cancellation\" in *'\"requestId\":20'*) ;; *) exit 1 ;; esac; \
case \"$cancellation\" in *'\"reason\":\"stop\"'*) ;; *) exit 1 ;; esac; \
case \"$cancellation\" in *'\"_meta\"'*|*'\"awaitCleanup\"'*) exit 1 ;; *) ;; esac; \
IFS= read -r tools || exit 1; \
case \"$tools\" in *'\"method\":\"tools/list\"'*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the cancellation client");
let request_id = RequestId::Number(20);
let mut waiter = client
.responses
.register(request_id.clone())
.expect("register modern cancellation owner");
client
.send_to_server(&JsonRpcMessage::Request(JsonRpcRequest::new(
"test/cancel",
Some(serde_json::json!({})),
20,
)))
.expect("commit request before modern cancellation");
client
.cancel_request(request_id, Some("stop".to_owned()))
.expect("modern cancellation writes one final stdio notification");
assert_eq!(
waiter
.try_response()
.expect_err("the live modern owner receives local cancellation")
.code,
McpErrorCode::RequestCancelled
);
client
.list_tools_typed(None)
.expect("the scripted peer admits only the exact final cancellation wire");
assert_eq!(client.responses.cancellation_control_len(), 1);
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[test]
fn modern_stdio_peer_cancellation_ignores_non_subscription_request_ids() {
let discovery = modern_discovery_response(
"modern-peer-cancellation-server",
&[MODERN_PROTOCOL_VERSION],
);
let script = format!(
"IFS= read -r discovery || exit 1; \
case \"$discovery\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery}' ;; *) exit 1 ;; esac; \
IFS= read -r first || exit 1; \
case \"$first\" in *'\"method\":\"tools/list\"'*'\"id\":2'*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\",\"params\":{{\"requestId\":2}}}}'; \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r second || exit 1; \
case \"$second\" in *'\"method\":\"tools/list\"'*'\"id\":3'*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; \
*) printf 'unexpected second frame: %s\\n' \"$second\" >&2; exit 1 ;; esac; \
exec sleep 2"
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the peer-cancellation client");
client
.list_tools_typed(None)
.expect("matching final peer cancellation must not release an ordinary waiter");
assert_eq!(client.responses.pending_len(), 0);
assert_eq!(client.responses.tombstone_len(), 0);
client
.list_tools_typed(None)
.expect("subsequent ordinary requests remain aligned");
assert_eq!(client.responses.tombstone_len(), 0);
assert!(client.is_initialized());
assert!(client.responses.terminal_error().is_none());
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn oversized_public_cancellation_is_local_first_then_connection_terminal() {
let mut client =
make_shell_scripted_initialized_client("exec sleep 2", Duration::from_secs(1));
let request_id = RequestId::Number(20);
let request = JsonRpcRequest::new("test/cancel-large", Some(serde_json::json!({})), 20);
let mut waiter = client
.responses
.register(request_id.clone())
.expect("register oversized-cancellation owner");
client
.send_to_server(&JsonRpcMessage::Request(request))
.expect("commit request before oversized cancellation");
let error = client
.cancel_request(request_id, Some("x".repeat(512)))
.expect_err("oversized atomic control must fail boundedly");
assert_eq!(error.message, CONTROL_FRAME_CAPACITY_ERROR);
let waiter_error = waiter
.try_response()
.expect_err("the first request-local outcome remains cancellation");
assert_eq!(waiter_error.code, McpErrorCode::RequestCancelled);
assert!(!client.is_initialized());
assert!(client.transport_is_closed());
assert!(client.child.is_none());
assert!(client.responses.terminal_error().is_some());
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn silent_peer_timeout_has_no_progress_callback_side_effect() {
let progress = JsonRpcMessage::Request(JsonRpcRequest::notification(
"notifications/progress",
Some(serde_json::json!({
"progressToken": 2,
"progress": 0.5,
"total": 1.0,
"message": "late"
})),
));
let mut client = make_peer_silent_past_deadline_client(progress);
let marker = ProgressMarker::Number(JsonInteger::from(2));
let mut progress_events = Vec::new();
let mut callback = |progress: f64, total: Option<f64>, message: Option<&str>| {
progress_events.push((progress, total, message.map(ToOwned::to_owned)));
};
let result: McpResult<serde_json::Value> = client.send_request_with_progress(
"test/late-progress-notification",
serde_json::json!({}),
2,
&marker,
&mut callback,
);
let error = result.expect_err("a silent peer must time out without progress");
assert!(error.message.contains("timed out"));
assert_eq!(progress_events.len(), 0);
assert!(client.is_initialized());
assert_eq!(client.responses.pending_len(), 0);
assert_eq!(client.responses.tombstone_len(), 1);
assert!(client.responses.terminal_error().is_none());
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn exact_valid_increasing_progress_resets_only_idle() {
let script = "IFS= read -r request; \
sleep 0.40; printf '{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{\"progressToken\":2,\"progress\":0.1,\"_meta\":{\"trace\":\"accepted\"}}}\\n'; \
sleep 0.40; printf '{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{\"progressToken\":2,\"progress\":0.2}}\\n'; \
sleep 0.40; printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"ok\":true}}\\n'; exec sleep 2";
let mut client = make_shell_scripted_initialized_client(script, Duration::from_secs(1));
client.timeout_policy =
RequestTimeoutPolicy::new(Duration::from_secs(1), Duration::from_secs(3)).unwrap();
let marker = ProgressMarker::Number(JsonInteger::from(2));
let mut progress_events = Vec::new();
let mut callback = |progress: f64, _total: Option<f64>, _message: Option<&str>| {
progress_events.push(progress);
};
let result: serde_json::Value = client
.send_request_with_progress(
"test/progress-idle-reset",
serde_json::json!({}),
2,
&marker,
&mut callback,
)
.expect("matching progress must keep the request alive between idle windows");
assert_eq!(result, serde_json::json!({"ok": true}));
assert_eq!(progress_events, vec![0.1, 0.2]);
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn matching_progress_does_not_reset_idle_when_policy_disables_it() {
let script = "IFS= read -r request; \
sleep 0.20; printf '{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{\"progressToken\":2,\"progress\":0.5}}\\n'; \
sleep 0.30; printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"tooLate\":true}}\\n'; exec sleep 2";
let mut client = make_shell_scripted_initialized_client(script, Duration::from_secs(1));
client.timeout_policy =
RequestTimeoutPolicy::new(Duration::from_millis(400), Duration::from_millis(900))
.unwrap()
.reset_idle_on_matching_progress(false);
let marker = ProgressMarker::Number(JsonInteger::from(2));
let mut progress_events = Vec::new();
let mut callback = |progress: f64, _total: Option<f64>, _message: Option<&str>| {
progress_events.push(progress);
};
let error = client
.send_request_with_progress::<_, serde_json::Value>(
"test/progress-reset-disabled",
serde_json::json!({}),
2,
&marker,
&mut callback,
)
.expect_err("accepted progress must not override a disabled idle reset");
assert_eq!(
error.data,
Some(serde_json::json!({"timeoutSource": "idle"}))
);
assert_eq!(progress_events, vec![0.5]);
assert_eq!(client.responses.cancellation_control_len(), 1);
assert!(client.responses.terminal_error().is_none());
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn unrelated_invalid_and_nonmonotonic_progress_do_not_reset_idle() {
let script = "IFS= read -r request; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{\"progressToken\":2,\"progress\":0.5}}\\n'; \
sleep 2; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{\"progressToken\":999,\"progress\":0.6}}\\n'; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{\"progressToken\":2,\"progress\":0.7,\"unknown\":true}}\\n'; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{\"progressToken\":2,\"progress\":0.5}}\\n'; \
sleep 9; printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"tooLate\":true}}\\n'; exec sleep 2";
let mut client = make_shell_scripted_initialized_client(script, Duration::from_secs(15));
client.timeout_policy =
RequestTimeoutPolicy::new(Duration::from_secs(10), Duration::from_secs(14)).unwrap();
let marker = ProgressMarker::Number(JsonInteger::from(2));
let mut progress_events = Vec::new();
let mut callback = |progress: f64, _total: Option<f64>, _message: Option<&str>| {
progress_events.push(progress);
};
let error = client
.send_request_with_progress::<_, serde_json::Value>(
"test/progress-no-idle-authority",
serde_json::json!({}),
2,
&marker,
&mut callback,
)
.expect_err("non-authoritative progress must not extend idle");
assert_eq!(
error.data,
Some(serde_json::json!({"timeoutSource": "idle"}))
);
assert_eq!(progress_events, vec![0.5]);
let terminal_error = client.responses.terminal_error();
assert!(
terminal_error.is_none(),
"non-authoritative progress must not terminate the connection: {terminal_error:?}"
);
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn matching_progress_never_moves_absolute_deadline() {
let script = "IFS= read -r request; \
sleep 0.10; printf '{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{\"progressToken\":2,\"progress\":0.1}}\\n'; \
sleep 0.10; printf '{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{\"progressToken\":2,\"progress\":0.2}}\\n'; \
sleep 0.10; printf '{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{\"progressToken\":2,\"progress\":0.3}}\\n'; \
sleep 0.30; printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"tooLate\":true}}\\n'; exec sleep 2";
let mut client = make_shell_scripted_initialized_client(script, Duration::from_secs(1));
client.timeout_policy =
RequestTimeoutPolicy::new(Duration::from_millis(300), Duration::from_millis(500))
.unwrap();
let marker = ProgressMarker::Number(JsonInteger::from(2));
let mut progress_events = Vec::new();
let mut callback = |progress: f64, _total: Option<f64>, _message: Option<&str>| {
progress_events.push(progress);
};
let error = client
.send_request_with_progress::<_, serde_json::Value>(
"test/progress-absolute-bound",
serde_json::json!({}),
2,
&marker,
&mut callback,
)
.expect_err("progress must not keep a request alive past absolute time");
assert_eq!(
error.data,
Some(serde_json::json!({"timeoutSource": "absolute"}))
);
assert_eq!(progress_events, vec![0.1, 0.2, 0.3]);
assert!(client.responses.terminal_error().is_none());
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn request_timeout_keeps_connection_reusable_and_discards_late_activity() {
let late_progress = JsonRpcMessage::Request(JsonRpcRequest::notification(
"notifications/progress",
Some(serde_json::json!({
"progressToken": 2,
"progress": 0.5,
"total": 1.0,
"message": "late"
})),
));
let late_response = JsonRpcMessage::Response(JsonRpcResponse::success(
RequestId::Number(2),
serde_json::json!({"late": true}),
));
let lines = [late_progress, late_response]
.map(|message| serde_json::to_string(&message).expect("serialize scripted message"));
assert!(
lines.iter().all(|line| !line.contains('\'')),
"the shell fixture requires single-quote-free JSON lines"
);
let script = format!(
"IFS= read -r first; sleep 1; IFS= read -r cancellation; \
IFS= read -r second; \
case \"$first\" in *'\"id\":2'*) first_ok=true;; *) first_ok=false;; esac; \
case \"$cancellation\" in *'\"method\":\"notifications/cancelled\"'*'\"requestId\":2'*) cancellation_ok=true;; *) cancellation_ok=false;; esac; \
case \"$second\" in *'\"id\":3'*) second_ok=true;; *) second_ok=false;; esac; \
printf '%s\\n' '{}' '{}'; \
printf '{{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{{\"first\":%s,\"cancellation\":%s,\"second\":%s}}}}\\n' \
\"$first_ok\" \"$cancellation_ok\" \"$second_ok\"; exec sleep 2",
lines[0], lines[1]
);
let mut client = make_shell_scripted_initialized_client(&script, Duration::from_millis(5));
let first_marker = ProgressMarker::Number(JsonInteger::from(2));
let mut first_progress = Vec::new();
let mut first_callback = |progress: f64, total: Option<f64>, message: Option<&str>| {
first_progress.push((progress, total, message.map(ToOwned::to_owned)));
};
let first: McpResult<serde_json::Value> = client.send_request_with_progress(
"test/first",
serde_json::json!({}),
2,
&first_marker,
&mut first_callback,
);
let first_error =
first.expect_err("the first request must time out while the peer is idle");
assert!(first_error.message.contains("timed out"));
assert_eq!(first_progress.len(), 0);
assert!(client.responses.terminal_error().is_none());
client.timeout_policy =
RequestTimeoutPolicy::new(Duration::from_secs(3), Duration::from_secs(3)).unwrap();
let second_marker = ProgressMarker::Number(JsonInteger::from(3));
let mut second_progress = Vec::new();
let mut second_callback = |progress: f64, total: Option<f64>, message: Option<&str>| {
second_progress.push((progress, total, message.map(ToOwned::to_owned)));
};
let second: serde_json::Value = client
.send_request_with_progress(
"test/second",
serde_json::json!({}),
3,
&second_marker,
&mut second_callback,
)
.expect("the next request must use the still-aligned connection");
assert_eq!(
second,
serde_json::json!({
"first": true,
"cancellation": true,
"second": true
})
);
assert_eq!(second_progress.len(), 0);
assert_eq!(client.responses.uncorrelated_diagnostics(), 0);
assert_eq!(client.responses.pending_len(), 0);
assert_eq!(client.responses.tombstone_len(), 0);
assert!(client.responses.terminal_error().is_none());
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn in_time_server_request_response_cannot_block_request_deadline() {
let script = "IFS= read -r request; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"ping\",\"id\":88}\\n'; \
IFS= read -r response; \
case \"$request\" in *'\"id\":2'*) request_ok=true;; *) request_ok=false;; esac; \
case \"$response\" in *'\"id\":88'*) response_ok=true;; *) response_ok=false;; esac; \
printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"request\":%s,\"response\":%s}}\\n' \
\"$request_ok\" \"$response_ok\"; exec sleep 2";
let mut client = make_shell_scripted_initialized_client(script, Duration::from_secs(2));
let result: serde_json::Value = client
.send_request("test/server-request", serde_json::json!({}))
.expect("an in-time server request must receive its bounded response");
assert_eq!(
result,
serde_json::json!({
"request": true,
"response": true
})
);
assert!(client.is_initialized());
assert!(!client.transport_is_closed());
assert!(client.responses.terminal_error().is_none());
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn selected_stdio_callback_writer_completes_while_reader_waits_for_peer() {
// The peer withholds the original response until it has read the
// callback completion. After admitting the callback request, the sole
// client reader has no inbound frame available and waits again. The
// callback can therefore complete only if selected ingress and egress
// retain independently locked halves.
let script = "IFS= read -r request; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"sampling/createMessage\",\"id\":41,\"params\":{\"messages\":[],\"maxTokens\":9}}\\n'; \
IFS= read -r sampling; \
case \"$request\" in *'\"id\":2'*) request_ok=true;; *) request_ok=false;; esac; \
case \"$sampling\" in *'\"id\":41'*) callback_id_ok=true;; *) callback_id_ok=false;; esac; \
case \"$sampling\" in *'\"model\":\"selected-half\"'*) callback_shape_ok=true;; *) callback_shape_ok=false;; esac; \
if $callback_id_ok && $callback_shape_ok; then callback_ok=true; else callback_ok=false; fi; \
printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"request\":%s,\"callback\":%s}}\\n' \
\"$request_ok\" \"$callback_ok\"; exec sleep 2";
let callback_calls = Arc::new(AtomicUsize::new(0));
let handlers = ReverseRequestHandlers::new().with_sampling_create_message({
let callback_calls = Arc::clone(&callback_calls);
move |_callback_cx, _cancellation, _params| {
let callback_calls = Arc::clone(&callback_calls);
Box::pin(async move {
callback_calls.fetch_add(1, Ordering::Relaxed);
Ok(CreateMessageResult::text("handled", "selected-half"))
})
}
});
let mut client = make_shell_scripted_initialized_client_with_reverse_handlers(
script,
Duration::from_secs(2),
handlers,
);
assert!(
client.selected_io.is_some(),
"the live legacy client activates selected shared halves before callbacks"
);
let result: serde_json::Value = client
.send_request("test/selected-reader", serde_json::json!({}))
.expect("callback writer must complete before the peer releases the response");
assert_eq!(
result,
serde_json::json!({"request": true, "callback": true})
);
assert_eq!(callback_calls.load(Ordering::Relaxed), 1);
assert!(client.is_initialized());
assert!(!client.transport_is_closed());
client
.close()
.expect("selected shared-half callback cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_legacy_reverse_request_handlers_remain_unchanged() {
let script = "IFS= read -r request; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"sampling/createMessage\",\"id\":41,\"params\":{\"messages\":[],\"maxTokens\":9}}\\n'; \
IFS= read -r sampling; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"roots/list\",\"id\":42}\\n'; \
IFS= read -r roots; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"elicitation/create\",\"id\":43,\"params\":{\"mode\":\"form\",\"message\":\"approval\",\"requestedSchema\":{\"type\":\"object\",\"properties\":{}}}}\\n'; \
IFS= read -r elicitation; \
case \"$sampling\" in *'\"model\":\"handler-model\"'*'\"id\":41'*) sampling_ok=true;; *) sampling_ok=false;; esac; \
case \"$roots\" in *'file:///workspace'*'\"id\":42'*) roots_ok=true;; *) roots_ok=false;; esac; \
case \"$elicitation\" in *'\"code\":-32601'*'\"id\":43'*) elicitation_rejected=true;; *) elicitation_rejected=false;; esac; \
printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"sampling\":%s,\"roots\":%s,\"elicitationRejected\":%s}}\\n' \
\"$sampling_ok\" \"$roots_ok\" \"$elicitation_rejected\"; exec sleep 2";
let sampling_calls = std::sync::Arc::new(AtomicUsize::new(0));
let roots_calls = std::sync::Arc::new(AtomicUsize::new(0));
let handlers = ReverseRequestHandlers::new()
.with_sampling_create_message({
let sampling_calls = std::sync::Arc::clone(&sampling_calls);
move |_callback_cx, _cancellation, params| {
let sampling_calls = std::sync::Arc::clone(&sampling_calls);
Box::pin(async move {
sampling_calls.fetch_add(1, Ordering::Relaxed);
assert_eq!(params.max_tokens, 9.into());
Ok(CreateMessageResult::text("handled", "handler-model"))
})
}
})
.with_roots_list({
let roots_calls = std::sync::Arc::clone(&roots_calls);
move |_callback_cx, _cancellation, _params| {
let roots_calls = std::sync::Arc::clone(&roots_calls);
Box::pin(async move {
roots_calls.fetch_add(1, Ordering::Relaxed);
Ok(ListRootsResult::new(vec![fastmcp_protocol::Root::new(
"file:///workspace",
)]))
})
}
});
let mut client = make_shell_scripted_initialized_client_with_reverse_handlers(
script,
Duration::from_secs(2),
handlers,
);
assert_eq!(
client.selected_protocol_era(),
Some(ProtocolEra::Legacy2024)
);
let result: serde_json::Value = client
.send_request("test/reverse-handlers", serde_json::json!({}))
.expect("configured reverse handlers must answer live server requests");
assert_eq!(
result,
serde_json::json!({"sampling": true, "roots": true, "elicitationRejected": true})
);
assert_eq!(sampling_calls.load(Ordering::Relaxed), 1);
assert_eq!(roots_calls.load(Ordering::Relaxed), 1);
assert!(client.is_initialized());
assert!(!client.transport_is_closed());
client.close().expect("client cleanup");
}
#[test]
fn reverse_callback_cancellation_after_handler_lock_prevents_invocation() {
let invoked = Arc::new(AtomicBool::new(false));
let handler: Arc<
dyn for<'callback> Fn(
&'callback Cx,
ReverseRequestCancellation,
(),
) -> ReverseRequestFuture<'callback, ()>
+ Send
+ Sync,
> = Arc::new({
let invoked = Arc::clone(&invoked);
move |_cx, _cancellation, ()| {
let invoked = Arc::clone(&invoked);
Box::pin(async move {
invoked.store(true, Ordering::Release);
Ok(())
})
}
});
let cancellation = ReverseRequestCancellation::new();
let worker_handler = Arc::clone(&handler);
let worker_cancellation = cancellation.clone();
let worker_cx = Cx::for_request();
let worker = std::thread::spawn(move || {
invoke_locked_reverse_request_handler(
&worker_cx,
&worker_handler,
worker_cancellation,
(),
)
});
cancellation.cancel();
let error = worker
.join()
.expect("callback worker must not panic")
.expect_err("cancellation admitted while waiting for the lock must win");
assert_eq!(error.code, McpErrorCode::RequestCancelled);
assert!(
!invoked.load(Ordering::Acquire),
"the handler must not run after cancellation wins the lock race"
);
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn protocol_sized_reverse_callback_response_preserves_follow_up_alignment() {
let script = "IFS= read -r request; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"sampling/createMessage\",\"id\":41,\"params\":{\"messages\":[],\"maxTokens\":9}}\\n'; \
IFS= read -r sampling; \
case \"$sampling\" in *'\"id\":41'*) callback_id_ok=true;; *) callback_id_ok=false;; esac; \
case \"$sampling\" in *'\"model\":\"large-model\"'*) callback_shape_ok=true;; *) callback_shape_ok=false;; esac; \
if $callback_id_ok && $callback_shape_ok; then shape_ok=true; else shape_ok=false; fi; \
case ${#sampling} in [0-9]|[0-9][0-9]|[0-9][0-9][0-9]|[0-9][0-9][0-9][0-9][0-9]) frame_ok=false;; *) frame_ok=true;; esac; \
printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"request\":true,\"frame\":%s,\"shape\":%s}}\\n' \"$frame_ok\" \"$shape_ok\"; \
IFS= read -r follow_up; \
case \"$follow_up\" in *'\"method\":\"test/alignment\"'*'\"id\":3'*) aligned_ok=true;; *) aligned_ok=false;; esac; \
printf '{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{\"aligned\":%s}}\\n' \"$aligned_ok\"; exec sleep 2";
let handlers = ReverseRequestHandlers::new().with_sampling_create_message(
|_callback_cx, _cancellation, _params| {
Box::pin(
async move { Ok(CreateMessageResult::text("x".repeat(2_048), "large-model")) },
)
},
);
let mut client = make_shell_scripted_initialized_client_with_reverse_handlers(
script,
Duration::from_secs(2),
handlers,
);
let first: serde_json::Value = client
.send_request("test/protocol-sized-callback", serde_json::json!({}))
.expect("a protocol-sized callback response must be framed normally");
assert_eq!(
first,
serde_json::json!({"request": true, "frame": true, "shape": true})
);
let follow_up: serde_json::Value = client
.send_request("test/alignment", serde_json::json!({}))
.expect("the following request remains frame-aligned");
assert_eq!(follow_up, serde_json::json!({"aligned": true}));
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn reverse_callback_shutdown_is_bounded_and_retains_noncooperative_worker() {
let script = "IFS= read -r request; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"sampling/createMessage\",\"id\":41,\"params\":{\"messages\":[],\"maxTokens\":9}}\\n'; \
printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"request\":true}}\\n'; exec sleep 2";
let started = Arc::new(AtomicBool::new(false));
let release = Arc::new(AtomicBool::new(false));
let handlers = ReverseRequestHandlers::new().with_sampling_create_message({
let started = Arc::clone(&started);
let release = Arc::clone(&release);
move |_callback_cx, _cancellation, _params| {
let started = Arc::clone(&started);
let release = Arc::clone(&release);
Box::pin(async move {
started.store(true, Ordering::Release);
while !release.load(Ordering::Acquire) {
std::thread::yield_now();
}
Ok(CreateMessageResult::text("released", "shutdown-test"))
})
}
});
let mut client = make_shell_scripted_initialized_client_with_reverse_handlers(
script,
Duration::from_secs(2),
handlers,
);
let response: serde_json::Value = client
.send_request("test/noncooperative-callback", serde_json::json!({}))
.expect("the peer response remains independently readable");
assert_eq!(response, serde_json::json!({"request": true}));
let start_deadline = Instant::now() + Duration::from_millis(250);
while !started.load(Ordering::Acquire) && Instant::now() < start_deadline {
std::thread::sleep(Duration::from_millis(1));
}
assert!(
started.load(Ordering::Acquire),
"callback must be running before shutdown"
);
let close_started = Instant::now();
let error = client
.close()
.expect_err("a noncooperative callback must bound explicit shutdown");
assert_eq!(error.message, REVERSE_CALLBACK_SHUTDOWN_TIMEOUT_ERROR);
assert!(
close_started.elapsed() < Duration::from_secs(1),
"explicit close must return within its callback-shutdown bound"
);
assert!(
!client
.reverse_callback_pool
.tasks
.lock()
.expect("reverse callback task registry remains inspectable")
.is_empty(),
"the timed-out task remains owned for a later join"
);
assert!(
!client.transport_is_closed(),
"a retained worker must not race transport teardown"
);
release.store(true, Ordering::Release);
client
.close()
.expect("released callback is joined before final transport teardown");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn reverse_callback_explicit_close_joins_every_observer_before_transport_teardown() {
let script = "IFS= read -r request; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"sampling/createMessage\",\"id\":41,\"params\":{\"messages\":[],\"maxTokens\":9}}\\n'; \
printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"request\":true}}\\n'; exec sleep 2";
let started = Arc::new(AtomicBool::new(false));
let handlers = ReverseRequestHandlers::new().with_sampling_create_message({
let started = Arc::clone(&started);
move |_callback_cx, cancellation, _params| {
let started = Arc::clone(&started);
Box::pin(async move {
started.store(true, Ordering::Release);
while !cancellation.is_cancel_requested() {
std::thread::yield_now();
}
cancellation.checkpoint()?;
Ok(CreateMessageResult::text("unreachable", "shutdown-test"))
})
}
});
let mut client = make_shell_scripted_initialized_client_with_reverse_handlers(
script,
Duration::from_secs(2),
handlers,
);
let response: serde_json::Value = client
.send_request("test/cooperative-callback", serde_json::json!({}))
.expect("the peer response remains independently readable");
assert_eq!(response, serde_json::json!({"request": true}));
let start_deadline = Instant::now() + Duration::from_millis(250);
while !started.load(Ordering::Acquire) && Instant::now() < start_deadline {
std::thread::sleep(Duration::from_millis(1));
}
assert!(
started.load(Ordering::Acquire),
"callback must be running before cooperative shutdown"
);
client
.close()
.expect("a cooperative callback is cancelled, joined, and then permits teardown");
assert!(
client
.reverse_callback_pool
.tasks
.lock()
.expect("reverse callback task registry remains inspectable")
.is_empty(),
"successful public close has observed every callback task completion"
);
assert!(
client.transport_is_closed(),
"transport teardown follows the cooperative callback join"
);
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn reverse_callback_drop_relies_on_region_ownership_without_cross_client_cleanup() {
let forbidden_registry = ["REVERSE_CALLBACK", "_SHUTDOWN_QUARANTINE"].concat();
let forbidden_handoff = ["retain_reverse", "_callback_shutdown_tasks"].concat();
let source = include_str!("lib.rs");
assert!(
!source.contains(&forbidden_registry) && !source.contains(&forbidden_handoff),
"callback shutdown must not retain a process-global task registry"
);
let script = "IFS= read -r request; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"sampling/createMessage\",\"id\":41,\"params\":{\"messages\":[],\"maxTokens\":9}}\\n'; \
printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"request\":true}}\\n'; exec sleep 2";
let started = Arc::new(AtomicBool::new(false));
let cancelled = Arc::new(AtomicBool::new(false));
let handlers = ReverseRequestHandlers::new().with_sampling_create_message({
let started = Arc::clone(&started);
let cancelled = Arc::clone(&cancelled);
move |_callback_cx, cancellation, _params| {
let started = Arc::clone(&started);
let cancelled = Arc::clone(&cancelled);
Box::pin(async move {
started.store(true, Ordering::Release);
while !cancellation.is_cancel_requested() {
std::thread::yield_now();
}
cancelled.store(true, Ordering::Release);
Err(McpError::request_cancelled())
})
}
});
let mut first = make_shell_scripted_initialized_client_with_reverse_handlers(
script,
Duration::from_secs(2),
handlers,
);
let response: serde_json::Value = first
.send_request("test/drop-owned-callback", serde_json::json!({}))
.expect("the peer response remains independently readable");
assert_eq!(response, serde_json::json!({"request": true}));
let start_deadline = Instant::now() + Duration::from_millis(250);
while !started.load(Ordering::Acquire) && Instant::now() < start_deadline {
std::thread::sleep(Duration::from_millis(1));
}
assert!(started.load(Ordering::Acquire));
drop(first);
let mut sibling =
make_shell_scripted_initialized_client("exec sleep 2", Duration::from_secs(1));
assert!(
sibling
.reverse_callback_pool
.tasks
.lock()
.expect("new client callback registry is inspectable")
.is_empty(),
"a fresh client has no dependency on a dropped client's callback observers"
);
let cancellation_deadline = Instant::now() + Duration::from_millis(250);
while !cancelled.load(Ordering::Acquire) && Instant::now() < cancellation_deadline {
std::thread::sleep(Duration::from_millis(1));
}
assert!(
cancelled.load(Ordering::Acquire),
"drop must request callback cancellation while its owner region settles the task"
);
sibling.close().expect("sibling client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_legacy_reverse_callback_cancellation_is_observable_without_blocking_reader() {
let script = "IFS= read -r request; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"sampling/createMessage\",\"id\":41,\"params\":{\"messages\":[],\"maxTokens\":9}}\\n'; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\",\"params\":{\"requestId\":41}}\\n'; \
printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"readerRemainedLive\":true}}\\n'; \
IFS= read -r follow_up; \
case \"$request\" in *'\"id\":2'*) request_ok=true;; *) request_ok=false;; esac; \
case \"$follow_up\" in *'\"method\":\"test/alignment\"'*'\"id\":3'*) aligned_ok=true;; *) aligned_ok=false;; esac; \
printf '{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{\"request\":%s,\"aligned\":%s}}\\n' \
\"$request_ok\" \"$aligned_ok\"; exec sleep 2";
let observed_cancellation = std::sync::Arc::new(AtomicBool::new(false));
let handlers = ReverseRequestHandlers::new().with_sampling_create_message({
let observed_cancellation = std::sync::Arc::clone(&observed_cancellation);
move |_callback_cx, cancellation, _params| {
let observed_cancellation = std::sync::Arc::clone(&observed_cancellation);
Box::pin(async move {
while !cancellation.is_cancel_requested() {
std::thread::yield_now();
}
observed_cancellation.store(true, Ordering::Release);
cancellation.checkpoint()?;
Ok(CreateMessageResult::text("cancelled", "cancelled"))
})
}
});
let mut client = make_shell_scripted_initialized_client_with_reverse_handlers(
script,
Duration::from_secs(2),
handlers,
);
let result: serde_json::Value = client
.send_request("test/reverse-callback-cancellation", serde_json::json!({}))
.expect("the sole reader must receive the caller response while the callback waits");
assert_eq!(result, serde_json::json!({"readerRemainedLive": true}));
let deadline = Instant::now() + Duration::from_millis(250);
while !observed_cancellation.load(Ordering::Acquire) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(1));
}
assert!(
observed_cancellation.load(Ordering::Acquire),
"the live callback must observe its matching server cancellation"
);
let follow_up: serde_json::Value = client
.send_request("test/alignment", serde_json::json!({}))
.expect("the reader remains usable after callback cancellation");
assert_eq!(
follow_up,
serde_json::json!({"request": true, "aligned": true})
);
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_legacy_reverse_callback_foreign_cancellation_does_not_cancel_callback() {
// This differs from the admitted cancellation path only by the server
// cancellation request ID: 42 is not the live callback's ID 41.
let script = "IFS= read -r request; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"sampling/createMessage\",\"id\":41,\"params\":{\"messages\":[],\"maxTokens\":9}}\\n'; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\",\"params\":{\"requestId\":42}}\\n'; \
printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"readerRemainedLive\":true}}\\n'; \
IFS= read -r next_one; \
IFS= read -r next_two; \
case \"$request\" in *'\"id\":2'*) request_ok=true;; *) request_ok=false;; esac; \
aligned_ok=false; callback_ok=false; \
for frame in \"$next_one\" \"$next_two\"; do \
case \"$frame\" in *'\"method\":\"test/alignment\"'*) \
case \"$frame\" in *'\"id\":3'*) aligned_ok=true;; esac;; esac; \
case \"$frame\" in *'\"model\":\"uncancelled\"'*) \
case \"$frame\" in *'\"id\":41'*) callback_ok=true;; esac;; esac; \
done; \
printf '{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{\"request\":%s,\"aligned\":%s,\"callback\":%s}}\\n' \
\"$request_ok\" \"$aligned_ok\" \"$callback_ok\"; exec sleep 2";
let release_callback = std::sync::Arc::new(AtomicBool::new(false));
let observed_foreign_cancellation = std::sync::Arc::new(AtomicBool::new(false));
let handlers = ReverseRequestHandlers::new().with_sampling_create_message({
let release_callback = std::sync::Arc::clone(&release_callback);
let observed_foreign_cancellation =
std::sync::Arc::clone(&observed_foreign_cancellation);
move |_callback_cx, cancellation, _params| {
let release_callback = std::sync::Arc::clone(&release_callback);
let observed_foreign_cancellation =
std::sync::Arc::clone(&observed_foreign_cancellation);
Box::pin(async move {
while !release_callback.load(Ordering::Acquire) {
if cancellation.is_cancel_requested() {
observed_foreign_cancellation.store(true, Ordering::Release);
return Err(McpError::request_cancelled());
}
std::thread::yield_now();
}
assert!(
!cancellation.is_cancel_requested(),
"a foreign cancellation ID must not affect this callback"
);
Ok(CreateMessageResult::text("handled", "uncancelled"))
})
}
});
let mut client = make_shell_scripted_initialized_client_with_reverse_handlers(
script,
Duration::from_secs(2),
handlers,
);
let result: serde_json::Value = client
.send_request("test/reverse-callback-cancellation", serde_json::json!({}))
.expect("a foreign cancellation must not block the caller response");
assert_eq!(result, serde_json::json!({"readerRemainedLive": true}));
release_callback.store(true, Ordering::Release);
let follow_up: serde_json::Value = client
.send_request("test/alignment", serde_json::json!({}))
.expect("the uncancelled callback response and later ping remain aligned");
assert_eq!(
follow_up,
serde_json::json!({"request": true, "aligned": true, "callback": true})
);
assert!(
!observed_foreign_cancellation.load(Ordering::Acquire),
"only the cancellation request ID differs from the admitted path"
);
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_modern_reverse_request_handlers_are_rejected_without_callback_mutation() {
let script = "IFS= read -r request; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"sampling/createMessage\",\"id\":41,\"params\":{\"messages\":[],\"maxTokens\":9}}\\n'; \
IFS= read -r sampling; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"roots/list\",\"id\":42}\\n'; \
IFS= read -r roots; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"elicitation/create\",\"id\":43,\"params\":{\"mode\":\"form\",\"message\":\"approval\",\"requestedSchema\":{\"type\":\"object\",\"properties\":{}}}}\\n'; \
IFS= read -r elicitation; \
case \"$sampling\" in *'\"code\":-32601'*) sampling_code_ok=true;; *) sampling_code_ok=false;; esac; \
case \"$sampling\" in *'\"id\":41'*) sampling_id_ok=true;; *) sampling_id_ok=false;; esac; \
if $sampling_code_ok && $sampling_id_ok; then sampling_rejected=true; else sampling_rejected=false; fi; \
case \"$roots\" in *'\"code\":-32601'*) roots_code_ok=true;; *) roots_code_ok=false;; esac; \
case \"$roots\" in *'\"id\":42'*) roots_id_ok=true;; *) roots_id_ok=false;; esac; \
if $roots_code_ok && $roots_id_ok; then roots_rejected=true; else roots_rejected=false; fi; \
case \"$elicitation\" in *'\"code\":-32601'*) elicitation_code_ok=true;; *) elicitation_code_ok=false;; esac; \
case \"$elicitation\" in *'\"id\":43'*) elicitation_id_ok=true;; *) elicitation_id_ok=false;; esac; \
if $elicitation_code_ok && $elicitation_id_ok; then elicitation_rejected=true; else elicitation_rejected=false; fi; \
printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"samplingRejected\":%s,\"rootsRejected\":%s,\"elicitationRejected\":%s}}\\n' \
\"$sampling_rejected\" \"$roots_rejected\" \"$elicitation_rejected\"; exec sleep 2";
let mut client = make_shell_scripted_initialized_client_for_version(
script,
Duration::from_secs(2),
MODERN_PROTOCOL_VERSION,
);
assert_eq!(
client.selected_protocol_era(),
Some(ProtocolEra::Modern2026)
);
let result: serde_json::Value = client
.send_request("test/reverse-handlers", serde_json::json!({}))
.expect("modern rejection of legacy reverse requests must keep the session aligned");
assert_eq!(
result,
serde_json::json!({
"samplingRejected": true,
"rootsRejected": true,
"elicitationRejected": true
})
);
assert!(client.is_initialized());
assert!(!client.transport_is_closed());
assert_eq!(client.responses.pending_len(), 0);
assert_eq!(client.responses.tombstone_len(), 0);
assert!(client.responses.terminal_error().is_none());
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_reverse_request_handlers_missing_handler_preserves_state() {
let script = "IFS= read -r request; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"sampling/createMessage\",\"id\":41,\"params\":{\"messages\":[],\"maxTokens\":9}}\\n'; \
IFS= read -r sampling; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"roots/list\",\"id\":42}\\n'; \
IFS= read -r roots; \
case \"$sampling\" in *'\"model\":\"handler-model\"'*'\"id\":41'*) sampling_ok=true;; *) sampling_ok=false;; esac; \
case \"$roots\" in *'\"code\":-32601'*'\"id\":42'*) roots_missing=true;; *) roots_missing=false;; esac; \
printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"sampling\":%s,\"rootsMissing\":%s}}\\n' \
\"$sampling_ok\" \"$roots_missing\"; exec sleep 2";
let sampling_calls = std::sync::Arc::new(AtomicUsize::new(0));
let roots_calls = std::sync::Arc::new(AtomicUsize::new(0));
let handlers = ReverseRequestHandlers::new().with_sampling_create_message({
let sampling_calls = std::sync::Arc::clone(&sampling_calls);
move |_callback_cx, _cancellation, _params| {
let sampling_calls = std::sync::Arc::clone(&sampling_calls);
Box::pin(async move {
sampling_calls.fetch_add(1, Ordering::Relaxed);
Ok(CreateMessageResult::text("handled", "handler-model"))
})
}
});
let mut client = make_shell_scripted_initialized_client_with_reverse_handlers(
script,
Duration::from_secs(2),
handlers,
);
let result: serde_json::Value = client
.send_request("test/reverse-handlers", serde_json::json!({}))
.expect("a missing reverse handler must not disturb the live session");
assert_eq!(
result,
serde_json::json!({"sampling": true, "rootsMissing": true})
);
assert_eq!(sampling_calls.load(Ordering::Relaxed), 1);
assert_eq!(
roots_calls.load(Ordering::Relaxed),
0,
"missing handler must leave state unchanged"
);
assert!(client.is_initialized());
assert!(!client.transport_is_closed());
assert_eq!(client.responses.pending_len(), 0);
assert_eq!(client.responses.tombstone_len(), 0);
assert!(client.responses.terminal_error().is_none());
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn panicked_progress_callback_cancels_and_preserves_connection_alignment() {
let script = "IFS= read -r first; \
printf '{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{\"progressToken\":2,\"progress\":0.5}}\\n'; \
IFS= read -r cancellation; IFS= read -r second; \
case \"$first\" in *'\"id\":2'*) first_ok=true;; *) first_ok=false;; esac; \
case \"$cancellation\" in *'\"method\":\"notifications/cancelled\"'*'\"requestId\":2'*) cancellation_ok=true;; *) cancellation_ok=false;; esac; \
case \"$second\" in *'\"id\":3'*) second_ok=true;; *) second_ok=false;; esac; \
printf '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"late\":true}}\\n'; \
printf '{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{\"first\":%s,\"cancellation\":%s,\"second\":%s}}\\n' \
\"$first_ok\" \"$cancellation_ok\" \"$second_ok\"; exec sleep 2";
let mut client = make_shell_scripted_initialized_client(script, Duration::from_secs(3));
let mut callback = |_progress: f64, _total: Option<f64>, _message: Option<&str>| {
panic!("progress callback panic canary");
};
let first = client.call_tool_with_progress(
"test/panicked-progress",
serde_json::json!({}),
&mut callback,
);
let first_error = first.expect_err("the callback panic must become a fixed local error");
assert_eq!(first_error.message, PROGRESS_CALLBACK_PANIC_ERROR);
assert_eq!(client.responses.pending_len(), 0);
assert_eq!(client.responses.tombstone_len(), 1);
assert!(client.responses.terminal_error().is_none());
let second: serde_json::Value = client
.send_request("test/after-panicked-progress", serde_json::json!({}))
.expect("the next request must remain aligned after callback cancellation");
assert_eq!(
second,
serde_json::json!({
"first": true,
"cancellation": true,
"second": true
})
);
assert_eq!(client.responses.tombstone_len(), 0);
assert_eq!(client.responses.uncorrelated_diagnostics(), 0);
assert!(client.responses.terminal_error().is_none());
assert!(client.is_initialized());
assert!(!client.transport_is_closed());
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn partial_frame_timeout_is_connection_terminal() {
let script = "printf '%s' '{\"jsonrpc\":\"2.0\",\"id\":2'; exec sleep 2";
let mut client = make_shell_scripted_initialized_client(script, Duration::from_millis(500));
std::thread::sleep(Duration::from_millis(50));
let result: McpResult<serde_json::Value> =
client.send_request("test/partial", serde_json::json!({}));
let error = result.expect_err("a timeout after partial-frame consumption must be terminal");
assert!(error.message.contains("timed out"));
assert_eq!(
error.data,
Some(serde_json::json!({"timeoutSource": "absolute"}))
);
assert!(!client.is_initialized());
assert!(client.transport_is_closed());
assert!(client.child.is_none());
let terminal = client
.responses
.terminal_error()
.expect("the framing failure must be retained");
assert_eq!(terminal.code, error.code);
assert_eq!(terminal.message, error.message);
assert_eq!(terminal.data, error.data);
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn stored_context_deadline_after_commit_is_connection_terminal() {
let mut client =
make_shell_scripted_initialized_client("exec sleep 2", Duration::from_secs(1));
let request_id = RequestId::Number(2);
let request = JsonRpcRequest::new("test/context-deadline", Some(serde_json::json!({})), 2);
let waiter = client
.responses
.register(request_id.clone())
.expect("register committed request");
client
.send_to_server(&JsonRpcMessage::Request(request))
.expect("commit request before expiring its stored context");
let deadlines = RequestDeadlines::start_at(client.timeout_policy, Instant::now()).unwrap();
client.cx = Cx::for_testing_with_budget(
asupersync::Budget::new().with_deadline(asupersync::Time::ZERO),
);
let error = client
.recv_response(waiter, deadlines)
.expect_err("an exhausted stored context must terminate the owned connection");
assert!(error.message.contains("timed out"));
assert!(!client.is_initialized());
assert!(client.transport_is_closed());
assert!(client.child.is_none());
assert_eq!(client.responses.tombstone_len(), 0);
assert!(client.responses.terminal_error().is_some());
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn stored_context_cancellation_after_commit_is_connection_terminal() {
let mut client =
make_shell_scripted_initialized_client("exec sleep 2", Duration::from_secs(1));
let request_id = RequestId::Number(2);
let request = JsonRpcRequest::new("test/context-cancel", Some(serde_json::json!({})), 2);
let waiter = client
.responses
.register(request_id)
.expect("register committed request");
client
.send_to_server(&JsonRpcMessage::Request(request))
.expect("commit request before cancelling its stored context");
let deadlines = RequestDeadlines::start_at(client.timeout_policy, Instant::now()).unwrap();
client.cx.set_cancel_requested(true);
let error = client
.recv_response(waiter, deadlines)
.expect_err("a cancelled stored context must terminate the owned connection");
assert_eq!(error.code, McpErrorCode::RequestCancelled);
assert!(!client.is_initialized());
assert!(client.transport_is_closed());
assert!(client.child.is_none());
assert_eq!(client.responses.pending_len(), 0);
assert_eq!(client.responses.tombstone_len(), 0);
let terminal = client
.responses
.terminal_error()
.expect("the cancellation must be retained as connection-terminal");
assert_eq!(terminal.code, error.code);
assert_eq!(terminal.message, error.message);
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn stored_context_cancellation_after_progress_commit_is_terminal() {
let mut client =
make_shell_scripted_initialized_client("exec sleep 2", Duration::from_secs(1));
let request_id = RequestId::Number(2);
let request = JsonRpcRequest::new(
"test/context-cancel-progress",
Some(serde_json::json!({})),
2,
);
let waiter = client
.responses
.register(request_id)
.expect("register committed progress request");
client
.send_to_server(&JsonRpcMessage::Request(request))
.expect("commit progress request before cancelling its stored context");
let timeout_policy = client.timeout_policy;
let deadlines = RequestDeadlines::start_at(timeout_policy, Instant::now()).unwrap();
client.cx.set_cancel_requested(true);
let marker = ProgressMarker::Number(JsonInteger::from(2));
let mut callback_invoked = false;
let mut callback = |_progress: f64, _total: Option<f64>, _message: Option<&str>| {
callback_invoked = true;
};
let error = client
.recv_response_with_progress(waiter, &marker, &mut callback, timeout_policy, deadlines)
.expect_err("a cancelled stored context must terminate the progress connection");
assert_eq!(error.code, McpErrorCode::RequestCancelled);
assert!(!callback_invoked);
assert!(!client.is_initialized());
assert!(client.transport_is_closed());
assert!(client.child.is_none());
assert_eq!(client.responses.pending_len(), 0);
assert_eq!(client.responses.tombstone_len(), 0);
assert!(client.responses.terminal_error().is_some());
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn complete_late_message_routes_unrelated_response_and_retires_tombstone() {
// The unrelated response must arrive through the real transport:
// routing now retains each response's raw admitted source frame, so a
// fabricated in-memory response (which no frame ever carried) is
// correctly refused by production code.
let mut client = make_shell_scripted_initialized_client(
r#"printf '%s
' '{"jsonrpc":"2.0","id":21,"result":{"owner":"unrelated"}}'; exec sleep 2"#,
Duration::from_secs(1),
);
let timed_out_id = RequestId::Number(20);
let unrelated_id = RequestId::Number(21);
let mut timed_out_waiter = client
.responses
.register(timed_out_id.clone())
.expect("register timed-out owner");
let mut unrelated_waiter = client
.responses
.register(unrelated_id.clone())
.expect("register unrelated owner");
let recv_cx = Cx::for_request();
let (unrelated_frame, _) = client
.recv_next_child_frame(&recv_cx, Some(Instant::now() + Duration::from_secs(2)))
.expect("scripted unrelated response arrives with its source frame");
let timeout = client.finish_timeout_after_complete_frame(
&timed_out_id,
unrelated_frame,
RequestTimeoutSource::Idle,
);
assert!(timeout.message.contains("timed out"));
let waiter_error = timed_out_waiter
.try_response()
.expect_err("the expired owner receives its local timeout");
assert_eq!(waiter_error.message, timeout.message);
let unrelated = unrelated_waiter
.try_response()
.expect("unrelated waiter remains valid")
.expect("the complete unrelated response is routed");
assert_eq!(unrelated.id, Some(unrelated_id));
assert_eq!(client.responses.tombstone_len(), 1);
assert_eq!(
client.responses.route(JsonRpcResponse::success(
timed_out_id,
serde_json::json!({"late": true}),
)),
ResponseRoute::TombstoneRetired
);
assert_eq!(client.responses.tombstone_len(), 0);
assert_eq!(client.responses.uncorrelated_diagnostics(), 0);
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn complete_late_server_request_uses_bounded_control_writes() {
let script = "IFS= read -r cancellation; IFS= read -r response; \
case \"$cancellation\" in *'\"method\":\"notifications/cancelled\"'*'\"requestId\":20'*) cancellation_ok=true;; *) cancellation_ok=false;; esac; \
case \"$response\" in *'\"id\":88'*) response_ok=true;; *) response_ok=false;; esac; \
printf '{\"jsonrpc\":\"2.0\",\"id\":99,\"result\":{\"cancellation\":%s,\"response\":%s}}\\n' \
\"$cancellation_ok\" \"$response_ok\"; exec sleep 2";
let mut client = make_shell_scripted_initialized_client(script, Duration::from_secs(1));
let timed_out_id = RequestId::Number(20);
let mut waiter = client
.responses
.register(timed_out_id.clone())
.expect("register timeout owner");
let late_ping =
JsonRpcMessage::Request(JsonRpcRequest::new("ping", Some(serde_json::json!({})), 88));
let late_ping = ReceivedTransportFrame::admit(
serde_json::to_vec(&late_ping).expect("serialize complete late request source"),
)
.expect("admit complete late request source");
let timeout = client.finish_timeout_after_complete_frame(
&timed_out_id,
late_ping,
RequestTimeoutSource::Idle,
);
assert!(timeout.message.contains("timed out"));
let waiter_error = waiter
.try_response()
.expect_err("the expired owner receives its timeout");
assert_eq!(waiter_error.message, timeout.message);
let (evidence, _) = recv_shared_child_transport(
&client.transport,
&client.cx,
Some(Instant::now() + Duration::from_secs(2)),
)
.expect("the peer observes both bounded control frames");
let JsonRpcMessage::Response(evidence) = evidence.into_message() else {
panic!("expected scripted evidence response");
};
assert_eq!(evidence.id, Some(RequestId::Number(99)));
assert_eq!(
evidence.result,
Some(serde_json::json!({
"cancellation": true,
"response": true
}))
);
assert!(!client.transport_is_closed());
assert_eq!(client.responses.tombstone_len(), 1);
client.close().expect("client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn malformed_complete_late_message_times_out_owner_and_closes_connection() {
let mut client =
make_shell_scripted_initialized_client("exec sleep 2", Duration::from_secs(1));
let request_id = RequestId::Number(30);
let mut waiter = client
.responses
.register(request_id.clone())
.expect("register timed-out owner");
let malformed =
ReceivedTransportFrame::admit(br#"{"jsonrpc":"1.0","result":null,"id":30}"#.to_vec())
.expect_err("strict frame admission rejects a non-2.0 response");
// Admission fails before a `ReceivedTransportFrame` exists, so the
// deadline owner must receive its already-elected local timeout before
// the framing failure is promoted to connection-terminal state.
let timeout = client.timeout_committed_request(&request_id, RequestTimeoutSource::Idle);
let _ = client.terminate_connection(transport_error_to_mcp(malformed));
assert!(timeout.message.contains("timed out"));
let waiter_error = waiter
.try_response()
.expect_err("the expired owner receives its first local outcome");
assert_eq!(waiter_error.message, timeout.message);
assert!(!client.is_initialized());
assert!(client.transport_is_closed());
assert!(client.child.is_none());
assert_eq!(client.responses.tombstone_len(), 0);
assert!(client.responses.terminal_error().is_some());
client.close().expect("client cleanup");
}
#[test]
fn command_resolution_preserves_path_lookup_and_anchors_relative_paths() {
assert_eq!(
resolve_stdio_command("server-on-path", None).unwrap(),
PathBuf::from("server-on-path")
);
let current = std::env::current_dir().unwrap();
assert_eq!(
resolve_stdio_command("./bin/server", Some(Path::new("workspace"))).unwrap(),
current.join("workspace").join("./bin/server")
);
}
#[test]
fn cancelled_context_rejects_direct_client_before_spawn() {
let cx = Cx::for_testing();
cx.set_cancel_requested(true);
let error = match Client::stdio_with_cx("definitely-not-a-command", &[], cx) {
Ok(_) => panic!("cancelled context must be rejected before spawn"),
Err(error) => error,
};
assert_eq!(error.code, McpErrorCode::RequestCancelled);
}
// ========================================
// method_not_found_response tests
// ========================================
#[test]
fn method_not_found_response_for_request() {
let request = JsonRpcRequest::new("sampling/createMessage", None, "req-1");
let response = method_not_found_response(&request);
assert!(response.is_some());
if let Some(JsonRpcMessage::Response(resp)) = response {
assert!(matches!(
resp.error.as_ref(),
Some(error)
if error.code
== JsonInteger::from(i64::from(i32::from(
fastmcp_core::McpErrorCode::MethodNotFound,
)))
));
assert_eq!(resp.id, Some(RequestId::String("req-1".to_string())));
} else {
assert!(matches!(response, Some(JsonRpcMessage::Response(_))));
}
}
#[test]
fn method_not_found_response_for_notification() {
let request = JsonRpcRequest::notification("notifications/message", None);
let response = method_not_found_response(&request);
assert!(response.is_none());
}
#[test]
fn notification_only_method_with_id_is_invalid_and_has_no_side_effect_kind() {
for method in [
"notifications/message",
"notifications/progress",
"notifications/resources/updated",
"notifications/tasks/status",
"notifications/vendor/extension",
] {
let request = JsonRpcRequest::new(method, None, "invalid-notification");
assert_eq!(server_notification_kind(&request), None);
let response = server_request_response(&request)
.expect("ID-bearing notification must receive an error response");
let JsonRpcMessage::Response(response) = response else {
panic!("expected response");
};
let error = response.error.expect("expected invalid-request error");
assert_eq!(
error.code,
JsonInteger::from(i64::from(i32::from(
fastmcp_core::McpErrorCode::InvalidRequest,
)))
);
}
}
#[test]
fn notification_side_effect_classification_requires_an_id_less_notification() {
let progress = JsonRpcRequest::notification("notifications/progress", None);
assert_eq!(
server_notification_kind(&progress),
Some(ServerNotificationKind::Progress)
);
let log = JsonRpcRequest::notification("notifications/message", None);
assert_eq!(
server_notification_kind(&log),
Some(ServerNotificationKind::LogMessage)
);
let request_only_notification = JsonRpcRequest::notification("ping", None);
assert_eq!(server_notification_kind(&request_only_notification), None);
}
#[test]
fn method_not_found_response_with_numeric_id() {
let request = JsonRpcRequest::new("unknown/method", None, 42i64);
let response = method_not_found_response(&request);
assert!(response.is_some());
if let Some(JsonRpcMessage::Response(resp)) = response {
assert_eq!(resp.id, Some(RequestId::Number(42)));
let error = resp.error.as_ref().unwrap();
assert_eq!(
error.code,
JsonInteger::from(i64::from(i32::from(
fastmcp_core::McpErrorCode::MethodNotFound,
)))
);
assert_eq!(error.message, "Method not found");
assert!(!error.message.contains("unknown/method"));
}
}
#[test]
fn method_not_found_response_with_params() {
let params = serde_json::json!({"key": "value"});
let request = JsonRpcRequest::new("roots/list", Some(params), "req-99");
let response = method_not_found_response(&request);
assert!(response.is_some());
if let Some(JsonRpcMessage::Response(resp)) = response {
let error = resp.error.as_ref().unwrap();
assert_eq!(error.message, "Method not found");
assert!(!error.message.contains("roots/list"));
}
}
#[test]
fn initializing_server_ping_request_is_not_serviced_before_era_selection() {
let request = JsonRpcRequest::new("ping", None, "server-ping");
let response = server_request_response(&request).expect("ping request has an ID");
let JsonRpcMessage::Response(response) = response else {
panic!("expected response");
};
assert_eq!(
response.id,
Some(RequestId::String("server-ping".to_string()))
);
assert!(response.result.is_none());
assert!(matches!(
response.error,
Some(error)
if error.code == JsonInteger::from(i64::from(i32::from(McpErrorCode::MethodNotFound)))
));
}
#[test]
fn response_envelope_requires_exact_version_and_one_outcome() {
let valid = JsonRpcResponse::success(RequestId::Number(1), serde_json::Value::Null);
assert!(validate_response_envelope(&valid).is_ok());
let wrong_version = JsonRpcResponse {
jsonrpc: std::borrow::Cow::Owned("2.1".to_string()),
..valid.clone()
};
assert!(validate_response_envelope(&wrong_version).is_err());
let both = JsonRpcResponse {
error: Some(JsonRpcError {
code: (-32_603).into(),
message: "failure".to_string(),
data: None,
}),
..valid.clone()
};
assert!(validate_response_envelope(&both).is_err());
let neither = JsonRpcResponse {
result: None,
error: None,
..valid
};
assert!(validate_response_envelope(&neither).is_err());
}
#[test]
fn response_validation_diagnostics_do_not_echo_peer_values() {
let version_canary = "PEER-VERSION-SECRET-CANARY\r\n";
let response = JsonRpcResponse {
jsonrpc: std::borrow::Cow::Owned(version_canary.to_string()),
result: Some(serde_json::Value::Null),
error: None,
id: Some(RequestId::String("PEER-ID-SECRET-CANARY\n".to_string())),
};
let envelope_error =
validate_response_envelope(&response).expect_err("an invalid version must fail closed");
assert_eq!(envelope_error.message, INVALID_RESPONSE_ENVELOPE_ERROR);
assert!(!envelope_error.message.contains(version_canary));
let id_canary = "PEER-ID-SECRET-CANARY\n";
let mismatched = JsonRpcResponse::success(
RequestId::String(id_canary.to_string()),
serde_json::Value::Null,
);
let id_error = validate_initialize_response_id(&mismatched)
.expect_err("a mismatched initialize ID must fail closed");
assert_eq!(id_error.message, INITIALIZE_RESPONSE_ID_ERROR);
assert!(!id_error.message.contains(id_canary));
let payload_canary = "PEER-PAYLOAD-SECRET-CANARY";
let payload_error =
decode_response_payload::<fastmcp_protocol::ListToolsResult>(serde_json::json!({
"tools": payload_canary
}))
.expect_err("a malformed typed response must fail closed");
assert_eq!(payload_error.message, INVALID_RESPONSE_PAYLOAD_ERROR);
assert!(!payload_error.message.contains(payload_canary));
}
#[test]
fn response_envelope_accepts_wire_null_result() {
let response: JsonRpcResponse =
serde_json::from_str(r#"{"jsonrpc":"2.0","result":null,"id":1}"#)
.expect("deserialize wire response");
assert_eq!(response.result, Some(serde_json::Value::Null));
assert!(response.error.is_none());
assert!(validate_response_envelope(&response).is_ok());
}
#[test]
fn response_envelope_rejects_wire_null_result_with_error() {
let error = serde_json::from_str::<JsonRpcResponse>(
r#"{"jsonrpc":"2.0","result":null,"error":{"code":-32603,"message":"failure"},"id":1}"#,
)
.expect_err("wire response with result and error must be rejected at decode");
assert!(error.to_string().contains("exactly one"));
}
#[test]
fn json_rpc_error_conversion_preserves_code_message_and_data() {
let error = json_rpc_error_to_mcp(JsonRpcError {
code: (-32_002).into(),
message: "forbidden".to_string(),
data: Some(serde_json::json!({"reason": "policy"})),
});
assert_eq!(error.code, McpErrorCode::ResourceForbidden);
assert_eq!(error.message, "forbidden");
assert_eq!(error.data, Some(serde_json::json!({"reason": "policy"})));
}
#[test]
fn json_rpc_error_conversion_retains_an_arbitrary_width_peer_code_diagnostic() {
let peer_code = "-999999999999999999999999999999999999999999999";
let error = json_rpc_error_to_mcp(JsonRpcError {
code: peer_code.parse().expect("huge JSON-RPC code is an integer"),
message: "peer rejected request".to_string(),
data: Some(serde_json::json!(["detail", 7])),
});
assert_eq!(error.code, McpErrorCode::InternalError);
assert_eq!(error.message, "peer rejected request");
let diagnostic = error.data.expect("wide peer code remains observable");
assert_eq!(diagnostic["jsonrpcErrorCode"].to_string(), peer_code);
assert_eq!(
diagnostic["jsonrpcErrorData"],
serde_json::json!(["detail", 7])
);
}
#[test]
fn json_rpc_error_conversion_retains_a_noncanonical_integer_code_spelling() {
let error = json_rpc_error_to_mcp(JsonRpcError {
code: "-326e2"
.parse()
.expect("a mathematical-integer JSON-RPC code is valid"),
message: "formatted peer code".to_string(),
data: None,
});
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert_eq!(
error
.data
.expect("noncanonical code spelling remains observable")["jsonrpcErrorCode"]
.to_string(),
"-326e2"
);
}
#[test]
fn initialize_response_requires_the_exact_request_id() {
let matching = JsonRpcResponse::success(
RequestId::Number(INITIALIZE_REQUEST_ID),
serde_json::Value::Null,
);
assert!(validate_initialize_response_id(&matching).is_ok());
for response in [
JsonRpcResponse::success(RequestId::Number(2), serde_json::Value::Null),
JsonRpcResponse::success(
RequestId::String(INITIALIZE_REQUEST_ID.to_string()),
serde_json::Value::Null,
),
JsonRpcResponse::error(None, McpError::internal_error("missing correlation").into()),
] {
let error = validate_initialize_response_id(&response)
.expect_err("a mismatched initialize response must fail closed");
assert_eq!(error.message, INITIALIZE_RESPONSE_ID_ERROR);
}
}
#[test]
fn initialize_result_rejects_an_unadvertised_protocol_version() {
let result = InitializeResult {
protocol_version: PROTOCOL_VERSION.to_string(),
capabilities: ServerCapabilities::default(),
server_info: ServerInfo {
name: "test-server".to_string(),
version: "1.0.0".to_string(),
},
instructions: None,
};
assert!(validate_initialize_result(&result).is_ok());
let unsupported = InitializeResult {
protocol_version: "2099-01-01".to_string(),
..result
};
let error = validate_initialize_result(&unsupported)
.expect_err("an unadvertised version must not become session authority");
assert_eq!(error.message, UNSUPPORTED_PROTOCOL_VERSION_ERROR);
assert!(!error.message.contains("2099-01-01"));
}
// ========================================
// transport_error_to_mcp tests
// ========================================
#[test]
fn transport_error_cancelled_maps_to_request_cancelled() {
let err = transport_error_to_mcp(TransportError::Cancelled);
assert_eq!(err.code, fastmcp_core::McpErrorCode::RequestCancelled);
}
#[test]
fn transport_error_closed_maps_to_internal() {
let err = transport_error_to_mcp(TransportError::Closed);
assert_eq!(err.code, fastmcp_core::McpErrorCode::InternalError);
assert!(err.message.contains("closed"));
}
#[test]
fn transport_error_timeout_maps_to_internal() {
let err = transport_error_to_mcp(TransportError::Timeout);
assert_eq!(err.code, fastmcp_core::McpErrorCode::InternalError);
assert!(err.message.contains("timed out"));
}
#[test]
fn transport_error_io_maps_to_internal() {
let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
let err = transport_error_to_mcp(TransportError::Io(io_err));
assert_eq!(err.code, fastmcp_core::McpErrorCode::InternalError);
assert!(err.message.contains("I/O error"));
}
#[test]
fn transport_error_codec_maps_to_internal() {
use fastmcp_transport::CodecError;
let codec_err = CodecError::MessageTooLarge(999_999);
let err = transport_error_to_mcp(TransportError::Codec(codec_err));
assert_eq!(err.code, fastmcp_core::McpErrorCode::InternalError);
assert_eq!(err.message, TRANSPORT_CODEC_ERROR);
}
#[test]
fn transport_codec_diagnostic_never_echoes_peer_text_or_controls() {
let canary = "PEER-CODEC-VARIANT-CANARY\r\n";
let source =
serde_json::from_value::<LogLevel>(serde_json::Value::String(canary.to_string()))
.expect_err("unknown peer enum variant must fail typed decoding");
let error = transport_error_to_mcp(TransportError::Codec(
fastmcp_transport::CodecError::Json(source),
));
assert_eq!(error.message, TRANSPORT_CODEC_ERROR);
assert!(!error.message.contains(canary));
assert!(!error.message.chars().any(char::is_control));
}
// ========================================
// ClientProgressParams tests
// ========================================
#[test]
fn client_progress_params_deserialization() {
let json = serde_json::json!({
"progressToken": 42,
"progress": 0.5,
"total": 1.0,
"message": "Halfway done"
});
let params: ClientProgressParams = serde_json::from_value(json).unwrap();
assert_eq!(params.marker, ProgressMarker::Number(JsonInteger::from(42)));
assert!((params.progress - 0.5).abs() < f64::EPSILON);
assert!((params.total.unwrap() - 1.0).abs() < f64::EPSILON);
assert_eq!(params.message.as_deref(), Some("Halfway done"));
}
#[test]
fn client_progress_params_preserve_lossless_numeric_marker() {
let json =
serde_json::from_str(r#"{"progressToken":9007199254740993123456789,"progress":0.5}"#)
.expect("large mathematical-integer progress marker is valid JSON");
let params: ClientProgressParams =
serde_json::from_value(json).expect("progress marker remains typed");
let ProgressMarker::Number(marker) = params.marker else {
panic!("numeric marker remains numeric");
};
assert_eq!(marker.as_str(), "9007199254740993123456789");
}
#[test]
fn client_progress_params_minimal() {
let json = serde_json::json!({
"progressToken": "tok-1",
"progress": 0.0
});
let params: ClientProgressParams = serde_json::from_value(json).unwrap();
assert_eq!(params.marker, ProgressMarker::String("tok-1".to_string()));
assert!(params.total.is_none());
assert!(params.message.is_none());
assert!(params.meta.is_none());
}
#[test]
fn progress_timer_authority_requires_closed_finite_strictly_increasing_params() {
let valid = serde_json::json!({
"progressToken": 42,
"progress": -1.5,
"total": -10.0,
"message": "still valid",
"_meta": {"trace": "accepted", "nested": {"open": true}}
});
let first =
parse_valid_client_progress(&valid, None).expect("first finite update is valid");
assert_eq!(first.progress.to_bits(), (-1.5_f64).to_bits());
assert_eq!(
first.meta.as_ref().and_then(|meta| meta.get("trace")),
Some(&serde_json::json!("accepted"))
);
assert!(parse_valid_client_progress(&valid, Some(-1.5)).is_none());
assert!(parse_valid_client_progress(&valid, Some(0.0)).is_none());
let increasing = serde_json::json!({"progressToken": 42, "progress": -1.0});
assert!(parse_valid_client_progress(&increasing, Some(-1.5)).is_some());
for invalid in [
serde_json::json!({"progressToken": 42, "progress": 0.0, "unknown": true}),
serde_json::json!({"progressToken": 42, "progress": 0.0, "total": null}),
serde_json::json!({"progressToken": 42, "progress": 0.0, "message": null}),
serde_json::json!({"progressToken": 42, "progress": 0.0, "_meta": null}),
serde_json::json!({"progressToken": 42, "progress": 0.0, "_meta": "wrong"}),
serde_json::json!({"progressToken": 42, "progress": "0.0"}),
] {
assert!(parse_valid_client_progress(&invalid, None).is_none());
}
}
#[test]
fn modern_progress_ingress_retains_decimal_and_exponent_lexemes() {
let frame = br#"{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"exact","progress":1.20e+4,"total":12000.0}}"#;
let request: JsonRpcRequest =
serde_json::from_slice(frame).expect("modern progress frame decodes structurally");
let raw_params = raw_notification_params_from_frame(frame)
.expect("client retains the exact notification params source")
.expect("modern progress has params");
let notification = decode_final_server_notification(&request, Some(&raw_params))
.expect("exact decimal and exponent progress are admitted on client ingress");
let ServerNotification::Progress(params) = notification else {
panic!("modern progress frame selects the exact final progress branch");
};
assert_eq!(params.progress.as_str(), "1.20e+4");
assert_eq!(
params.total.as_ref().map(|total| total.as_str()),
Some("12000.0")
);
}
#[test]
fn modern_progress_ingress_rejects_total_below_progress() {
let frame = br#"{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"exact","progress":1.20e+4,"total":11999.0}}"#;
let request: JsonRpcRequest = serde_json::from_slice(frame)
.expect("one-variable over-total frame decodes structurally");
let raw_params = raw_notification_params_from_frame(frame)
.expect("client retains the exact notification params source")
.expect("modern progress has params");
let notification = decode_final_server_notification(&request, Some(&raw_params))
.expect("the final progress schema does not order progress and total");
assert!(
matches!(
notification,
ServerNotification::Progress(params)
if params.progress.as_str() == "1.20e+4"
&& params.total.as_ref().is_some_and(|total| total.as_str() == "11999.0")
),
"changing only total below progress retains both exact wire values"
);
}
#[test]
fn remote_log_metadata_never_contains_peer_text_or_controls() {
let canary = "REMOTE-LOG-SECRET-CANARY";
let message = LogMessageParams {
level: LogLevel::Warning,
logger: Some(format!("{canary}\r\n\u{1b}[31m{}", "x".repeat(70_000))),
data: serde_json::Value::String(format!("{canary}\n\t\u{0}{}", "y".repeat(70_000))),
};
let formatted = remote_log_metadata(&message).to_string();
assert_eq!(REMOTE_LOG_TARGET, "fastmcp_rust::remote");
assert!(!formatted.contains(canary));
assert!(!formatted.chars().any(char::is_control));
assert!(formatted.contains("level=warning"));
assert!(formatted.contains("logger_bytes=oversized"));
assert!(formatted.contains("data_kind=string"));
assert!(formatted.contains("data_extent=oversized"));
assert!(formatted.len() < 160, "metadata must remain bounded");
}
#[test]
fn remote_log_metadata_reports_only_container_shape() {
let canary = "OBJECT-KEY-AND-VALUE-CANARY";
let mut object = serde_json::Map::new();
object.insert(
canary.to_string(),
serde_json::json!([canary, "\r\n\u{1b}"]),
);
let message = LogMessageParams {
level: LogLevel::Error,
logger: None,
data: serde_json::Value::Object(object),
};
let formatted = remote_log_metadata(&message).to_string();
assert!(!formatted.contains(canary));
assert!(!formatted.chars().any(char::is_control));
assert!(formatted.contains("logger_present=false"));
assert!(formatted.contains("data_kind=object"));
assert!(formatted.contains("data_extent=small"));
}
#[test]
fn insert_final_request_log_level_stamps_only_when_configured() {
let mut metadata = serde_json::Map::new();
insert_final_request_log_level(&mut metadata, None)
.expect("absent logLevel leaves request metadata unchanged");
assert!(metadata.is_empty());
insert_final_request_log_level(&mut metadata, Some(LoggingLevel::Info))
.expect("a configured logLevel is request metadata");
assert_eq!(
metadata.get(FINAL_LOG_LEVEL_META_KEY),
Some(&serde_json::json!("info"))
);
insert_final_request_log_level(&mut metadata, Some(LoggingLevel::Emergency))
.expect("changing only the rank overwrites the same metadata key");
assert_eq!(
metadata.get(FINAL_LOG_LEVEL_META_KEY),
Some(&serde_json::json!("emergency"))
);
}
#[test]
fn public_http_client_exposes_final_notification_take() {
let _: fn(&mut HttpClient) -> Vec<ServerNotification> =
HttpClient::take_final_server_notifications;
let _: fn(&mut HttpClient) -> Vec<FinalProgressNotificationParams> =
HttpClient::take_final_progress_notifications;
}
#[test]
fn final_log_message_sink_projection_preserves_all_legacy_levels() {
let message = FinalLogMessageParams {
level: LoggingLevel::Warning,
logger: Some("server.audit".to_string()),
data: serde_json::json!({"event": "tool-complete"}),
meta: Some(
OpenMetadata::try_from_entries([(
"com.example/trace".to_string(),
serde_json::json!("retained"),
)])
.expect("valid open final metadata"),
),
additional: std::collections::BTreeMap::from([(
"com.example/extension".to_string(),
serde_json::json!(true),
)]),
};
let projection = final_log_message_sink_projection(&message);
assert_eq!(projection.level, LogLevel::Warning);
assert_eq!(projection.logger.as_deref(), Some("server.audit"));
assert_eq!(
projection.data,
serde_json::json!({"event": "tool-complete"})
);
assert_eq!(
message
.meta
.as_ref()
.and_then(|meta| meta.get("com.example/trace")),
Some(&serde_json::json!("retained"))
);
assert_eq!(
message.additional.get("com.example/extension"),
Some(&serde_json::json!(true))
);
for (level, expected) in [
(LoggingLevel::Debug, LogLevel::Debug),
(LoggingLevel::Info, LogLevel::Info),
(LoggingLevel::Notice, LogLevel::Notice),
(LoggingLevel::Warning, LogLevel::Warning),
(LoggingLevel::Error, LogLevel::Error),
(LoggingLevel::Critical, LogLevel::Critical),
(LoggingLevel::Alert, LogLevel::Alert),
(LoggingLevel::Emergency, LogLevel::Emergency),
] {
let projected = final_log_message_sink_projection(&FinalLogMessageParams {
level,
logger: Some("server.audit".to_owned()),
data: serde_json::json!("event"),
meta: None,
additional: std::collections::BTreeMap::new(),
});
assert_eq!(projected.level, expected);
}
}
#[test]
fn client_legacy_log_level_mappings_preserve_all_eight_severities() {
for (legacy, final_level, wire) in [
(LogLevel::Debug, LoggingLevel::Debug, "debug"),
(LogLevel::Info, LoggingLevel::Info, "info"),
(LogLevel::Notice, LoggingLevel::Notice, "notice"),
(LogLevel::Warning, LoggingLevel::Warning, "warning"),
(LogLevel::Error, LoggingLevel::Error, "error"),
(LogLevel::Critical, LoggingLevel::Critical, "critical"),
(LogLevel::Alert, LoggingLevel::Alert, "alert"),
(LogLevel::Emergency, LoggingLevel::Emergency, "emergency"),
] {
assert_eq!(final_log_level(legacy), final_level);
assert_eq!(legacy_log_level(final_level), legacy);
let metadata = remote_log_metadata(&LogMessageParams {
level: legacy,
logger: None,
data: serde_json::Value::Null,
})
.to_string();
assert!(metadata.contains(&format!("level={wire}")));
}
}
#[test]
fn automatic_pagination_limits_are_locked_to_the_security_budget() {
assert_eq!(MAX_AUTO_PAGINATION_PAGES, 1_024);
assert_eq!(MAX_AUTO_PAGINATION_ITEMS, 100_000);
assert_eq!(MAX_AUTO_PAGINATION_SERIALIZED_BYTES, 64 * 1_024 * 1_024);
assert_eq!(MAX_PAGINATION_CURSOR_BYTES, 4 * 1_024);
}
#[test]
fn pagination_budget_rejects_oversized_and_repeated_cursors_without_echoing_them() {
let mut budget = PaginationBudget::new();
let exact_limit = "x".repeat(MAX_PAGINATION_CURSOR_BYTES);
assert_eq!(
budget
.admit_next_cursor(Some(exact_limit.clone()))
.expect("cursor at the byte limit is admitted"),
Some(exact_limit)
);
let oversized_canary = format!(
"OVERSIZED-CURSOR-SECRET\r\n\u{1b}{}",
"z".repeat(MAX_PAGINATION_CURSOR_BYTES)
);
let oversized = budget
.admit_next_cursor(Some(oversized_canary.clone()))
.expect_err("oversized cursor must fail closed");
assert_eq!(oversized.message, PAGINATION_CURSOR_LIMIT_ERROR);
assert!(!oversized.message.contains(&oversized_canary));
assert!(!oversized.message.contains("OVERSIZED-CURSOR-SECRET"));
assert!(!oversized.message.chars().any(char::is_control));
let repeated_canary = "REPEATED-CURSOR-SECRET\n\u{1b}".to_string();
budget
.admit_next_cursor(Some(repeated_canary.clone()))
.expect("first cursor occurrence is admitted");
let repeated = budget
.admit_next_cursor(Some(repeated_canary.clone()))
.expect_err("cursor cycle must fail closed");
assert_eq!(repeated.message, PAGINATION_CURSOR_CYCLE_ERROR);
assert!(!repeated.message.contains(&repeated_canary));
assert!(!repeated.message.chars().any(char::is_control));
}
#[test]
fn pagination_budget_enforces_page_item_and_byte_limits() {
let limits = PaginationLimits {
pages: 2,
items: 2,
serialized_bytes: 6,
cursor_bytes: 16,
};
let mut budget = PaginationBudget::with_limits(limits);
budget.begin_page().expect("first page");
budget.begin_page().expect("second page");
let page_error = budget
.begin_page()
.expect_err("third page exceeds the configured bound");
assert_eq!(page_error.message, PAGINATION_PAGE_LIMIT_ERROR);
budget
.account_page(&[1_u8])
.expect("the first three-byte JSON page fits");
budget
.account_page(&[2_u8])
.expect("the second three-byte JSON page fits exactly");
let item_error = budget
.account_page(&[3_u8])
.expect_err("the third item exceeds the configured bound");
assert_eq!(item_error.message, PAGINATION_ITEM_LIMIT_ERROR);
let mut byte_budget = PaginationBudget::with_limits(PaginationLimits {
serialized_bytes: 2,
..limits
});
let byte_canary = "PAGINATION-BYTE-SECRET\r\n";
let byte_error = byte_budget
.account_page(&[byte_canary])
.expect_err("serialized page above the byte bound must fail closed");
assert_eq!(byte_error.message, PAGINATION_BYTE_LIMIT_ERROR);
assert!(!byte_error.message.contains(byte_canary));
assert!(!byte_error.message.chars().any(char::is_control));
}
#[test]
fn pagination_budget_checked_arithmetic_fails_closed() {
let mut page_budget = PaginationBudget::new();
page_budget.pages = usize::MAX;
assert_eq!(
page_budget
.begin_page()
.expect_err("page counter overflow must fail closed")
.message,
PAGINATION_PAGE_LIMIT_ERROR
);
let mut item_budget = PaginationBudget::with_limits(PaginationLimits {
items: usize::MAX,
..PaginationLimits::DEFAULT
});
item_budget.items = usize::MAX;
assert_eq!(
item_budget
.account_page(&[0_u8])
.expect_err("item counter overflow must fail closed")
.message,
PAGINATION_ITEM_LIMIT_ERROR
);
}
#[test]
fn bounded_list_page_suppresses_peer_cursor_after_local_item_truncation() {
let page = bounded_list_page(
vec![1_u8, 2, 3],
None,
Some("next-page".to_owned()),
ListPageLimits::new(2, 64),
)
.expect("bounded page");
assert_eq!(page.items, vec![1, 2]);
assert!(page.next_cursor.is_none());
assert!(page.local_truncated);
assert!(page.peer_has_more);
}
#[test]
fn bounded_list_page_preserves_advancing_peer_cursor_when_page_is_complete() {
let page = bounded_list_page(
vec![1_u8, 2],
Some("current-page"),
Some("next-page".to_owned()),
ListPageLimits::new(2, 64),
)
.expect("bounded page");
assert_eq!(page.items, vec![1, 2]);
assert_eq!(page.next_cursor.as_deref(), Some("next-page"));
assert!(!page.local_truncated);
assert!(page.peer_has_more);
}
#[test]
fn bounded_list_page_stops_before_serialized_byte_budget_is_exceeded() {
let page = bounded_list_page(
vec!["small", "this item is too large"],
None,
None,
ListPageLimits::new(8, 10),
)
.expect("bounded page");
assert_eq!(page.items, vec!["small"]);
assert!(page.local_truncated);
assert!(!page.peer_has_more);
assert!(page.next_cursor.is_none());
assert!(measure_serialized_bytes(&page.items, 10).is_ok());
}
#[test]
fn bounded_list_page_counts_brackets_commas_and_items_in_byte_budget() {
let empty = bounded_list_page(Vec::<u8>::new(), None, None, ListPageLimits::new(0, 2))
.expect("empty vector exactly fits two bytes");
assert_eq!(empty.items.len(), 0);
assert!(!empty.local_truncated);
assert_eq!(measure_serialized_bytes(&empty.items, 2).unwrap(), 2);
let bracket_only_budget =
bounded_list_page(vec![0_u8], None, None, ListPageLimits::new(1, 2))
.expect("the retained empty vector still fits");
assert_eq!(bracket_only_budget.items.len(), 0);
assert!(bracket_only_budget.local_truncated);
let single = bounded_list_page(vec![0_u8], None, None, ListPageLimits::new(1, 3))
.expect("[0] exactly fits three bytes");
assert_eq!(single.items, vec![0]);
assert!(!single.local_truncated);
let pair = bounded_list_page(vec![0_u8, 1], None, None, ListPageLimits::new(2, 5))
.expect("[0,1] exactly fits five bytes");
assert_eq!(pair.items, vec![0, 1]);
assert!(!pair.local_truncated);
let missing_comma_budget =
bounded_list_page(vec![0_u8, 1], None, None, ListPageLimits::new(2, 4))
.expect("the first item still fits");
assert_eq!(missing_comma_budget.items, vec![0]);
assert!(missing_comma_budget.local_truncated);
assert_eq!(
measure_serialized_bytes(&missing_comma_budget.items, 4).unwrap(),
3
);
}
#[test]
fn bounded_list_page_accepts_zero_items_but_rejects_sub_empty_vec_byte_limits() {
let zero_items = bounded_list_page(vec![0_u8], None, None, ListPageLimits::new(0, 2))
.expect("zero retained items is a valid caller budget");
assert_eq!(zero_items.items.len(), 0);
assert!(zero_items.local_truncated);
for byte_limit in [0, 1] {
let limits = ListPageLimits::new(1, byte_limit);
let error = validate_list_page_request(None, limits)
.expect_err("a byte budget smaller than [] must be rejected");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(error.message, LIST_PAGE_BYTE_LIMIT_ERROR);
let internal_error = bounded_list_page::<u8>(Vec::new(), None, None, limits)
.expect_err("the bounded-page helper must enforce the same contract");
assert_eq!(internal_error.code, McpErrorCode::InvalidParams);
assert_eq!(internal_error.message, LIST_PAGE_BYTE_LIMIT_ERROR);
}
}
#[test]
fn bounded_list_page_rejects_oversized_cursors_without_echoing_them() {
let cursor = format!("CURSOR-SECRET{}", "x".repeat(MAX_PAGINATION_CURSOR_BYTES));
let error = bounded_list_page::<u8>(
Vec::new(),
None,
Some(cursor.clone()),
ListPageLimits::new(1, 16),
)
.expect_err("oversized peer cursor must fail closed");
assert_eq!(error.message, PAGINATION_CURSOR_LIMIT_ERROR);
assert!(!error.message.contains(&cursor));
let input_error = validate_list_page_request(Some(&cursor), ListPageLimits::new(1, 16))
.expect_err("oversized caller cursor must fail before sending");
assert_eq!(input_error.message, PAGINATION_CURSOR_LIMIT_ERROR);
assert!(!input_error.message.contains(&cursor));
}
#[test]
fn bounded_list_page_rejects_a_non_advancing_peer_cursor_without_echoing_it() {
let cursor = "NO-PROGRESS-CURSOR-SECRET";
let error = bounded_list_page::<u8>(
Vec::new(),
Some(cursor),
Some(cursor.to_owned()),
ListPageLimits::new(1, 16),
)
.expect_err("the response cursor must advance beyond the request cursor");
assert_eq!(error.message, PAGINATION_CURSOR_NO_PROGRESS_ERROR);
assert!(!error.message.contains(cursor));
}
#[test]
fn bounded_page_methods_validate_arguments_before_auto_initialization() {
let mut client = make_closed_client(false);
let invalid_limits = ListPageLimits::new(1, 1);
let oversized_cursor = "x".repeat(MAX_PAGINATION_CURSOR_BYTES + 1);
for error in [
client
.list_tools_page(None, invalid_limits)
.expect_err("tool page limits must fail locally"),
client
.list_resources_page(Some(&oversized_cursor), ListPageLimits::new(1, 2))
.expect_err("resource page cursor must fail locally"),
client
.list_resource_templates_page(None, invalid_limits)
.expect_err("template page limits must fail locally"),
client
.list_prompts_page(Some(&oversized_cursor), ListPageLimits::new(1, 2))
.expect_err("prompt page cursor must fail locally"),
] {
assert_eq!(error.code, McpErrorCode::InvalidParams);
}
assert!(!client.is_initialized());
assert!(client.initialization_error.is_none());
assert!(client.child.is_some());
}
#[test]
fn panicked_tool_progress_callback_returns_fixed_safe_error() {
let panic_canary = "PROGRESS-PANIC-SECRET\r\n\u{1b}";
let mut callback = |_progress: f64, _total: Option<f64>, _message: Option<&str>| {
panic!("{panic_canary}");
};
let callback_error = invoke_tool_progress_callback(&mut callback, 0.5, Some(1.0), None)
.expect_err("callback panic must be contained");
assert_eq!(callback_error.message, PROGRESS_CALLBACK_PANIC_ERROR);
assert!(!callback_error.message.contains(panic_canary));
assert!(!callback_error.message.chars().any(char::is_control));
}
// ========================================
// Response pump correlation tests
// ========================================
#[test]
fn response_registry_preserves_reordered_responses_for_exact_waiters() {
let mut registry = ResponseRegistry::new();
let first_id = RequestId::Number(1);
let second_id = RequestId::Number(2);
let mut first = registry.register(first_id.clone()).expect("first waiter");
let mut second = registry.register(second_id.clone()).expect("second waiter");
assert_eq!(
registry.route(JsonRpcResponse::success(
second_id.clone(),
serde_json::json!({"owner": "second"}),
)),
ResponseRoute::Delivered
);
assert!(
first
.try_response()
.expect("first waiter remains valid")
.is_none(),
"a reordered response must not wake the wrong waiter"
);
let second_response = second
.try_response()
.expect("second waiter is valid")
.expect("second response is retained");
assert_eq!(second_response.id, Some(second_id));
assert_eq!(
second_response.response.result,
Some(serde_json::json!({"owner": "second"}))
);
assert_eq!(
registry.route(JsonRpcResponse::success(
first_id.clone(),
serde_json::json!({"owner": "first"}),
)),
ResponseRoute::Delivered
);
let first_response = first
.try_response()
.expect("first waiter is valid")
.expect("first response is retained");
assert_eq!(first_response.id, Some(first_id));
assert_eq!(
first_response.response.result,
Some(serde_json::json!({"owner": "first"}))
);
assert_eq!(registry.pending_len(), 0);
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn negotiated_stdio_executor_clones_route_reordered_exact_result_sources() {
let second_raw_result = r#"{"owner":"second","exact":1.20e+4}"#;
let first_raw_result = r#"{"owner":"first","exact":7.30e-2}"#;
let script = format!(
"IFS= read -r _; IFS= read -r _; \\
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{second_raw_result}}}'; \\
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{first_raw_result}}}'; \\
exec sleep 2"
);
let mut client = make_shell_scripted_initialized_client(&script, Duration::from_secs(1));
let cx = Cx::for_request();
let executor = client
.multiplexed_stdio_executor()
.expect("selected client exposes a cloneable request executor");
let mut first = executor
.execute(&cx, "ping", Some(serde_json::json!({})))
.expect("first direct executor request commits");
let mut second = executor
.clone()
.execute(&cx, "ping", Some(serde_json::json!({})))
.expect("second cloned executor request commits");
assert_eq!(first.request_id(), &RequestId::Number(2));
assert_eq!(second.request_id(), &RequestId::Number(3));
client
.drive_multiplexed_stdio(&cx)
.expect("client-owned ingress admits the reordered second response first");
client
.drive_multiplexed_stdio(&cx)
.expect("client-owned ingress admits the retained first response second");
let (second_response, second_source) = executor
.try_take_response_with_raw_result(&mut second)
.expect("second handle remains owned by the cloneable executor")
.expect("second handle receives its reordered final");
assert_eq!(second_response.id, Some(RequestId::Number(3)));
assert_eq!(
second_source.as_deref(),
Some(second_raw_result),
"the exact source remains with the reordered response owner"
);
let (first_response, first_source) = executor
.try_take_response_with_raw_result(&mut first)
.expect("first handle remains owned by the original executor")
.expect("first handle receives its retained final");
assert_eq!(first_response.id, Some(RequestId::Number(2)));
assert_eq!(first_source.as_deref(), Some(first_raw_result));
client.close().expect("reordered stdio client cleanup");
}
#[cfg(unix)]
fn assert_modern_multiplexed_subscription_cancellation(cancellation_request_id: i64) {
let script = format!(
"IFS= read -r request; \\
case \"$request\" in *'\"method\":\"subscriptions/listen\"'*'\"id\":2'*) \\
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\",\"params\":{{\"requestId\":{cancellation_request_id}}}}}' ;; *) exit 1 ;; esac; \\
exec sleep 2"
);
let mut client = make_shell_scripted_initialized_client_for_version(
&script,
Duration::from_secs(1),
MODERN_PROTOCOL_VERSION,
);
let cx = Cx::for_request();
let executor = client
.multiplexed_stdio_executor()
.expect("selected modern client exposes a multiplexed executor");
let mut execution = executor
.execute(
&cx,
"subscriptions/listen",
Some(serde_json::json!({"notifications": {"toolsListChanged": true}})),
)
.expect("modern subscription owner commits before peer cancellation");
client
.drive_multiplexed_stdio(&cx)
.expect("the shared ingress admits the server cancellation frame");
if cancellation_request_id == 2 {
let error = executor
.try_take_response(&mut execution)
.expect_err("a matching modern teardown cancels its multiplexed owner");
assert_eq!(error.code, McpErrorCode::RequestCancelled);
assert_eq!(
executor.executor.take_cancellation_events(),
vec![CancellationRequested {
request_id: RequestId::Number(2),
reason: ExecutionTerminalReason::PeerSubscriptionTeardown,
}]
);
} else {
assert!(
executor
.try_take_response(&mut execution)
.expect("a foreign modern cancellation leaves the live owner intact")
.is_none()
);
assert_eq!(executor.executor.take_cancellation_events().len(), 0);
}
client
.close()
.expect("modern multiplexed cancellation cleanup");
}
#[cfg(unix)]
#[test]
fn modern_multiplexed_subscription_cancellation_reaches_its_public_owner() {
assert_modern_multiplexed_subscription_cancellation(2);
}
#[cfg(unix)]
#[test]
fn modern_multiplexed_subscription_ignores_only_a_foreign_cancellation_id() {
assert_modern_multiplexed_subscription_cancellation(3);
}
#[cfg(all(unix, feature = "legacy-2024-11-05"))]
fn assert_negotiated_stdio_drop_cancellation(drop_first: bool) {
// The peer emits its final pair immediately only after it observes the
// cancellation control. Without that third frame, its background
// branch emits the same IDs one second later. The two tests below
// differ solely in whether the first request-owned handle is dropped.
let script = r#"
IFS= read -r _
IFS= read -r _
(
sleep 1
printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"owner":"first","control":"retained"}}'
printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"owner":"second","control":"retained"}}'
) &
if IFS= read -r control; then
case "$control" in
*notifications/cancelled*)
printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"owner":"first","control":"dropped"}}'
printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"owner":"second","control":"dropped"}}'
;;
esac
fi
"#;
let mut client = make_shell_scripted_initialized_client(script, Duration::from_secs(2));
let cx = Cx::for_request();
let executor = client
.multiplexed_stdio_executor()
.expect("selected client exposes a cloneable request executor");
let mut first = executor
.execute(&cx, "ping", Some(serde_json::json!({})))
.expect("first request commits");
let mut second = executor
.clone()
.execute(&cx, "ping", Some(serde_json::json!({})))
.expect("second request commits");
let expected_control = if drop_first {
drop(first);
client
.drive_multiplexed_stdio(&cx)
.expect("client services the dropped owner's cancellation before reading");
client
.drive_multiplexed_stdio(&cx)
.expect("late tombstoned final cannot consume the retained second handle");
"dropped"
} else {
let first_response = client
.wait_multiplexed_request(&cx, &mut first)
.expect("retained first handle completes without a cancellation control");
assert_eq!(
first_response
.result
.as_ref()
.and_then(|result| result.get("control"))
.and_then(serde_json::Value::as_str),
Some("retained")
);
"retained"
};
let second_response = if drop_first {
executor
.try_take_response(&mut second)
.expect("retained second handle is still owned")
.expect("retained second handle completes after the late tombstoned first final")
} else {
client
.wait_multiplexed_request(&cx, &mut second)
.expect("sequential adapter waits through the same client-owned ingress")
};
assert_eq!(second_response.id, Some(RequestId::Number(3)));
assert_eq!(
second_response
.result
.as_ref()
.and_then(|result| result.get("control"))
.and_then(serde_json::Value::as_str),
Some(expected_control),
"the peer observes cancellation only when the first owner is dropped"
);
assert_eq!(
executor.executor.take_cancellation_events().len(),
usize::from(drop_first),
"the retained-vs-dropped pair differs by exactly one local cancellation event"
);
client
.close()
.expect("drop-cancellation stdio client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn dropped_negotiated_stdio_handle_tombstones_its_late_final_and_cancels_once() {
assert_negotiated_stdio_drop_cancellation(true);
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn retained_negotiated_stdio_handle_does_not_emit_drop_cancellation() {
assert_negotiated_stdio_drop_cancellation(false);
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn dropping_client_fails_a_surviving_negotiated_stdio_handle() {
let client = make_shell_scripted_initialized_client(
"while IFS= read -r _; do :; done",
Duration::from_secs(2),
);
let cx = Cx::for_request();
let executor = client
.multiplexed_stdio_executor()
.expect("selected client exposes its cloneable executor");
let mut execution = executor
.execute(&cx, "ping", Some(serde_json::json!({})))
.expect("request commits before its client owner is dropped");
drop(client);
let error = executor
.try_take_response(&mut execution)
.expect_err("a surviving handle observes the client-drop terminal outcome");
assert_eq!(error.code, McpErrorCode::InternalError);
assert_eq!(error.message, "Client connection closed");
}
#[test]
fn response_registry_unknown_id_does_not_consume_or_wake_waiter() {
let mut registry = ResponseRegistry::new();
let expected_id = RequestId::Number(7);
let mut waiter = registry
.register(expected_id.clone())
.expect("expected waiter");
assert_eq!(
registry.route(JsonRpcResponse::success(
RequestId::String("7".to_string()),
serde_json::json!({"wrong": true}),
)),
ResponseRoute::UnknownId
);
assert_eq!(registry.pending_len(), 1);
assert!(
waiter
.try_response()
.expect("expected waiter remains valid")
.is_none()
);
assert_eq!(
registry.route(JsonRpcResponse::success(
expected_id.clone(),
serde_json::json!({"right": true}),
)),
ResponseRoute::Delivered
);
let response = waiter
.try_response()
.expect("expected waiter is valid")
.expect("matching response arrives");
assert_eq!(response.id, Some(expected_id));
}
#[test]
fn response_registry_tombstone_consumes_exact_late_response_without_diagnostic() {
let mut registry = ResponseRegistry::new();
let request_id = RequestId::Number(8);
let mut waiter = registry
.register(request_id.clone())
.expect("register timeout owner");
let timeout = McpError::internal_error("Request timed out");
assert!(
registry
.tombstone(&request_id, timeout.clone())
.expect("record tombstone")
);
assert_eq!(registry.pending_len(), 0);
assert_eq!(registry.tombstone_len(), 1);
let waiter_error = waiter
.try_response()
.expect_err("the waiter receives its timeout outcome");
assert_eq!(waiter_error.message, timeout.message);
let reuse_error = registry
.register(request_id.clone())
.expect_err("a tombstoned ID cannot acquire a new owner");
assert!(reuse_error.message.contains("Retired request ID"));
assert_eq!(
registry.route(JsonRpcResponse::success(
request_id,
serde_json::json!({"late": true}),
)),
ResponseRoute::TombstoneRetired
);
assert_eq!(registry.tombstone_len(), 0);
assert_eq!(registry.uncorrelated_diagnostics, 0);
}
#[test]
fn response_registry_precommit_abandonment_retains_no_late_response_route() {
let mut registry = ResponseRegistry::new();
let request_id = RequestId::Number(81);
let _waiter = registry
.register(request_id.clone())
.expect("request obtains a local correlation owner");
assert!(registry.abandon_before_commit(&request_id));
assert_eq!(registry.pending_len(), 0);
assert_eq!(registry.tombstone_len(), 0);
assert_eq!(
registry.route(JsonRpcResponse::success(
request_id,
serde_json::json!({"impossible": "uncommitted"}),
)),
ResponseRoute::UnknownId,
"a pre-commit cancellation retains no route because no upstream frame exists"
);
}
#[test]
fn response_registry_postcommit_cancellation_retains_late_response_route() {
let mut registry = ResponseRegistry::new();
let request_id = RequestId::Number(82);
let _waiter = registry
.register(request_id.clone())
.expect("request obtains a local correlation owner");
assert!(
registry
.tombstone(&request_id, McpError::request_cancelled())
.expect("committed request can be retired")
);
assert_eq!(registry.pending_len(), 0);
assert_eq!(registry.tombstone_len(), 1);
assert_eq!(
registry.route(JsonRpcResponse::success(
request_id,
serde_json::json!({"late": "committed"}),
)),
ResponseRoute::TombstoneRetired,
"a committed cancellation retains one late-response route"
);
}
#[test]
fn response_registry_combined_correlation_bound_includes_tombstones() {
let mut registry = ResponseRegistry::new();
let expires_at = Instant::now()
.checked_add(RESPONSE_TOMBSTONE_RETENTION)
.expect("test clock must admit the fixed retention interval");
registry
.tombstones
.extend((0..MAX_RESPONSE_CORRELATIONS).map(|id| {
(
RequestId::String(format!("retired-{id}"))
.correlation_key()
.expect("test IDs are valid"),
expires_at,
)
}));
let error = registry
.register(RequestId::String("over-capacity".to_string()))
.expect_err("tombstones must count against correlation capacity");
assert!(error.message.contains("correlation limit"));
assert_eq!(registry.pending_len(), 0);
assert_eq!(registry.tombstone_len(), MAX_RESPONSE_CORRELATIONS);
registry.fail_all(error);
assert_eq!(registry.tombstone_len(), 0);
}
#[test]
fn response_registry_expired_tombstones_release_correlation_capacity() {
let mut registry = ResponseRegistry::new();
registry
.tombstones
.extend((0..MAX_RESPONSE_CORRELATIONS).map(|id| {
(
RequestId::String(format!("expired-{id}"))
.correlation_key()
.expect("test IDs are valid"),
Instant::now(),
)
}));
let waiter = registry
.register(RequestId::String("new-owner".to_string()))
.expect("expired tombstones must be pruned before admission");
assert_eq!(registry.tombstone_len(), 0);
assert_eq!(registry.pending_len(), 1);
drop(waiter);
}
#[test]
fn cancellation_control_marker_is_at_most_once_per_request_generation() {
let mut registry = ResponseRegistry::new();
let request_id = RequestId::Number(23);
assert!(
registry
.claim_cancellation_control(&request_id)
.expect("first arbitrary-ID control claim")
);
assert!(
!registry
.claim_cancellation_control(&request_id)
.expect("duplicate arbitrary-ID claim")
);
assert_eq!(registry.cancellation_control_len(), 1);
let waiter = registry
.register(request_id.clone())
.expect("a new waiter generation is not poisoned by the old marker");
assert_eq!(registry.cancellation_control_len(), 0);
assert!(
registry
.claim_cancellation_control(&request_id)
.expect("the admitted generation owns one fresh control claim")
);
assert!(
registry.register(request_id).is_err(),
"duplicate waiter admission must fail before clearing the live marker"
);
assert_eq!(registry.cancellation_control_len(), 1);
drop(waiter);
}
#[test]
fn cancellation_control_markers_have_bounded_absolute_lifetime() {
assert_eq!(
CANCELLATION_CONTROL_RETENTION, MAX_CLIENT_ABSOLUTE_TIMEOUT,
"one marker must cover the longest ordinary request generation"
);
let mut registry = ResponseRegistry::new();
let expired_id = RequestId::String("expired-control".to_string());
registry.cancellation_controls.insert(
expired_id.correlation_key().expect("test ID is valid"),
Instant::now(),
);
assert!(
registry
.claim_cancellation_control(&expired_id)
.expect("an exactly expired marker releases the ID")
);
assert_eq!(registry.cancellation_control_len(), 1);
let expires_at = Instant::now()
.checked_add(CANCELLATION_CONTROL_RETENTION)
.expect("test clock admits fixed control retention");
registry.cancellation_controls.clear();
registry
.cancellation_controls
.extend((0..MAX_CANCELLATION_CONTROL_IDS).map(|id| {
(
RequestId::String(format!("control-{id}"))
.correlation_key()
.expect("test IDs are valid"),
expires_at,
)
}));
let error = registry
.claim_cancellation_control(&RequestId::String("overflow".to_string()))
.expect_err("control retention has a deterministic hard bound");
assert!(error.message.contains("retention limit"));
assert_eq!(
registry.cancellation_control_len(),
MAX_CANCELLATION_CONTROL_IDS
);
}
#[test]
fn response_registry_correlates_numeric_aliases() {
let mut registry = ResponseRegistry::new();
let mut waiter = registry
.register(RequestId::Number(1))
.expect("the first numeric request claims one correlation key");
let response = JsonRpcResponse::success(
RequestId::Integer("1e0".to_owned()),
serde_json::Value::Null,
);
assert_eq!(
registry.route(response),
ResponseRoute::Delivered,
"a mathematically equivalent numeric response reaches the live waiter"
);
let delivered = waiter
.try_response()
.expect("the live waiter receives its correlated response")
.expect("the response was delivered synchronously");
assert_eq!(delivered.id, Some(RequestId::Integer("1e0".to_owned())));
assert_eq!(registry.pending_len(), 0);
}
#[test]
fn response_registry_rejects_invalid_direct_integer_without_mutation() {
let mut registry = ResponseRegistry::new();
let baseline = RequestId::Integer("1".to_owned());
let planted_invalid = RequestId::Integer("1.5".to_owned());
let waiter = registry
.register(baseline)
.expect("the baseline mathematical integer request is admitted");
let state_before = registry.pending_len();
let error = registry
.register(planted_invalid)
.expect_err("changing only the lexeme to a fractional value cannot claim a slot");
assert!(error.message.contains("Invalid JSON-RPC request ID"));
assert_eq!(
registry.pending_len(),
state_before,
"the directly constructed rejected ID leaves the live correlation state unchanged"
);
drop(waiter);
}
#[test]
fn response_registry_rejects_live_numeric_alias_without_mutation() {
let mut registry = ResponseRegistry::new();
let waiter = registry
.register(RequestId::Number(1))
.expect("the baseline request is admitted");
let state_before = registry.pending_len();
assert!(
registry
.register(RequestId::Integer("1.0".to_owned()))
.is_err(),
"an exact numeric alias cannot create a second active request"
);
assert_eq!(registry.pending_len(), state_before);
drop(waiter);
}
#[test]
fn response_registry_invalid_envelope_fails_all_waiters() {
let mut registry = ResponseRegistry::new();
let mut waiter = registry
.register(RequestId::Number(7))
.expect("register waiter");
let response = JsonRpcResponse {
jsonrpc: std::borrow::Cow::Owned("1.0".to_string()),
result: Some(serde_json::Value::Null),
error: None,
id: Some(RequestId::Number(7)),
};
assert_eq!(registry.route(response), ResponseRoute::InvalidEnvelope);
let error = waiter
.try_response()
.expect_err("invalid envelope is connection-terminal");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert_eq!(error.message, INVALID_RESPONSE_ENVELOPE_ERROR);
}
#[test]
fn response_registry_missing_id_fails_every_waiter_consistently() {
let mut registry = ResponseRegistry::new();
let mut first = registry
.register(RequestId::Number(10))
.expect("first waiter");
let mut second = registry
.register(RequestId::Number(11))
.expect("second waiter");
let missing_id_response = JsonRpcResponse::error(
None,
McpError::internal_error("uncorrelated peer error").into(),
);
assert_eq!(
registry.route(missing_id_response),
ResponseRoute::MissingId
);
assert_eq!(registry.pending_len(), 0);
let first_error = first
.try_response()
.expect_err("missing ID must fail first waiter");
let second_error = second
.try_response()
.expect_err("missing ID must fail second waiter");
assert_eq!(first_error.code, second_error.code);
assert_eq!(first_error.message, second_error.message);
assert!(first_error.message.contains("missing a request ID"));
let future_error = registry
.register(RequestId::Number(12))
.expect_err("failed connection rejects new waiter");
assert_eq!(future_error.message, first_error.message);
}
#[test]
fn response_registry_connection_loss_wakes_all_waiters_with_same_error() {
let mut registry = ResponseRegistry::new();
let mut first = registry
.register(RequestId::Number(20))
.expect("first waiter");
let mut second = registry
.register(RequestId::Number(21))
.expect("second waiter");
let connection_error = McpError::internal_error("Transport closed");
assert_eq!(registry.fail_all(connection_error.clone()), 2);
assert_eq!(registry.fail_all(connection_error), 0);
let first_error = first
.try_response()
.expect_err("connection loss wakes first waiter");
let second_error = second
.try_response()
.expect_err("connection loss wakes second waiter");
assert_eq!(first_error.message, "Transport closed");
assert_eq!(second_error.message, first_error.message);
}
#[test]
fn response_registry_keeps_a_routed_success_when_connection_later_fails() {
let mut registry = ResponseRegistry::new();
let completed_id = RequestId::Number(22);
let pending_id = RequestId::Number(23);
let mut completed = registry
.register(completed_id.clone())
.expect("completed waiter");
let mut pending = registry.register(pending_id).expect("pending waiter");
assert_eq!(
registry.route(JsonRpcResponse::success(
completed_id.clone(),
serde_json::json!({"terminal": "response"}),
)),
ResponseRoute::Delivered
);
assert_eq!(
registry.fail_all(McpError::internal_error("connection failed afterward")),
1
);
let completed_response = completed
.try_response()
.expect("the first terminal outcome remains authoritative")
.expect("routed response is retained");
assert_eq!(completed_response.id, Some(completed_id));
assert_eq!(
pending
.try_response()
.expect_err("still-pending waiter receives connection failure")
.message,
"connection failed afterward"
);
}
#[test]
fn response_registry_request_error_wakes_only_its_owner() {
let mut registry = ResponseRegistry::new();
let first_id = RequestId::Number(25);
let second_id = RequestId::Number(26);
let mut first = registry.register(first_id.clone()).expect("first waiter");
let mut second = registry.register(second_id.clone()).expect("second waiter");
assert!(registry.fail(
&first_id,
McpError::internal_error("first request timed out")
));
let first_error = first
.try_response()
.expect_err("request-local error wakes its owner");
assert_eq!(first_error.message, "first request timed out");
assert!(
second
.try_response()
.expect("second waiter remains valid")
.is_none(),
"a request-local error must not wake a sibling waiter"
);
assert_eq!(
registry.route(JsonRpcResponse::success(
second_id.clone(),
serde_json::json!("second"),
)),
ResponseRoute::Delivered
);
let second_response = second
.try_response()
.expect("second waiter remains valid")
.expect("second waiter receives its response");
assert_eq!(second_response.id, Some(second_id));
}
#[test]
fn response_registry_duplicate_registration_preserves_original_waiter() {
let mut registry = ResponseRegistry::new();
let id = RequestId::Number(30);
let mut original = registry.register(id.clone()).expect("original waiter");
let duplicate_error = registry
.register(id.clone())
.expect_err("duplicate ID must be rejected");
assert!(duplicate_error.message.contains("Duplicate in-flight"));
assert_eq!(registry.pending_len(), 1);
assert_eq!(
registry.route(JsonRpcResponse::success(
id.clone(),
serde_json::json!("original"),
)),
ResponseRoute::Delivered
);
let response = original
.try_response()
.expect("original waiter remains valid")
.expect("original waiter receives response");
assert_eq!(response.id, Some(id.clone()));
assert_eq!(
registry.route(JsonRpcResponse::success(id, serde_json::json!("duplicate"),)),
ResponseRoute::UnknownId,
"a second terminal response is late peer activity"
);
}
#[test]
fn response_registry_dropped_waiter_cannot_be_replaced() {
let mut registry = ResponseRegistry::new();
let id = RequestId::Number(40);
let waiter = registry.register(id.clone()).expect("waiter");
drop(waiter);
assert_eq!(
registry.route(JsonRpcResponse::success(id, serde_json::json!(true))),
ResponseRoute::WaiterDropped
);
assert_eq!(registry.pending_len(), 0);
}
#[test]
fn response_registry_bounds_unknown_id_diagnostics() {
let mut registry = ResponseRegistry::new();
for id in 0..u16::from(MAX_UNCORRELATED_RESPONSE_DIAGNOSTICS) + 5 {
assert_eq!(
registry.route(JsonRpcResponse::success(
RequestId::Number(i64::from(id)),
serde_json::Value::Null,
)),
ResponseRoute::UnknownId
);
}
assert_eq!(
registry.uncorrelated_diagnostics,
MAX_UNCORRELATED_RESPONSE_DIAGNOSTICS
);
}
#[test]
fn response_registry_enforces_and_releases_in_flight_bound() {
let mut registry = ResponseRegistry::new();
for id in 0..MAX_IN_FLIGHT_RESPONSES {
#[allow(clippy::cast_possible_wrap)]
let waiter = registry
.register(RequestId::Number(id as i64))
.expect("waiter below bound");
drop(waiter);
}
assert_eq!(registry.pending_len(), MAX_IN_FLIGHT_RESPONSES);
let capacity_error = registry
.register(RequestId::String("over-capacity".to_string()))
.expect_err("waiter above bound must fail");
assert!(capacity_error.message.contains("limit reached"));
assert_eq!(
registry.route(JsonRpcResponse::success(
RequestId::Number(0),
serde_json::Value::Null,
)),
ResponseRoute::WaiterDropped
);
let replacement = registry
.register(RequestId::String("replacement".to_string()))
.expect("terminal cleanup releases one slot");
drop(replacement);
assert_eq!(registry.pending_len(), MAX_IN_FLIGHT_RESPONSES);
}
#[test]
fn terminal_send_failure_wakes_all_registered_waiters() {
let mut client = make_closed_client(true);
let first_id = RequestId::Number(50);
let second_id = RequestId::Number(51);
let mut first = client
.responses
.register(first_id.clone())
.expect("first waiter");
let mut second = client.responses.register(second_id).expect("second waiter");
let error = client.record_send_failure(
Some(&first_id),
TransportError::Io(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"connection lost",
)),
);
assert!(error.message.contains("connection lost"));
assert!(
!client.is_initialized(),
"a terminal transport failure must clear initialized state"
);
assert_eq!(client.responses.pending_len(), 0);
for waiter in [&mut first, &mut second] {
let waiter_error = waiter
.try_response()
.expect_err("terminal send failure must wake every waiter");
assert_eq!(waiter_error.message, error.message);
}
assert!(
client.responses.register(RequestId::Number(52)).is_err(),
"a terminal send failure permanently closes registration"
);
assert!(client.child.is_none(), "terminal failure reaps the child");
let later = client
.cancel_request(50_i64, None)
.expect_err("initialized APIs must not retry a terminal connection");
assert_eq!(later.code, error.code);
assert_eq!(later.message, error.message);
}
#[test]
fn client_close_wakes_registered_waiter_before_transport_teardown() {
let mut client = make_closed_client(true);
let mut waiter = client
.responses
.register(RequestId::Number(55))
.expect("waiter");
client.close().expect("client cleanup");
let error = waiter
.try_response()
.expect_err("close must publish a terminal waiter outcome");
assert_eq!(error.message, "Client connection closed");
assert!(!client.is_initialized());
assert!(client.ping().is_err());
client
.close()
.expect("repeated successful close must be idempotent");
}
#[test]
fn reality_check_regression_terminal_cleanup_failure_cannot_become_later_success() {
let mut client = make_closed_client(true);
client.cleanup_error = Some(McpError::internal_error(
"deterministic retained cleanup failure",
));
let first = client
.close()
.expect_err("retained cleanup failure must be observable");
let second = client
.close()
.expect_err("terminal cleanup failure must remain sticky");
assert!(first.message.contains("cleanup failure"));
assert!(second.message.contains("cleanup failure"));
}
#[test]
fn reality_check_regression_completed_process_retry_clears_transient_failure() {
let mut client = make_closed_client(true);
client.pending_process_cleanup_error = Some(McpError::internal_error(
"previous retryable process-cleanup timeout",
));
client.child_cleanup_phase = ClientChildCleanupPhase::Complete;
client
.close()
.expect("completed cleanup must clear a transient prior attempt");
assert!(client.pending_process_cleanup_error.is_none());
assert!(!client.is_initialized());
}
#[test]
fn request_encoding_failure_is_isolated_to_its_registered_owner() {
let mut client = make_closed_client(true);
let first_id = RequestId::Number(60);
let second_id = RequestId::Number(61);
let mut first = client
.responses
.register(first_id.clone())
.expect("first waiter");
let mut second = client
.responses
.register(second_id.clone())
.expect("second waiter");
let error = client.record_send_failure(
Some(&first_id),
TransportError::Codec(fastmcp_transport::CodecError::MessageTooLarge(1_000_000)),
);
assert_eq!(error.message, TRANSPORT_CODEC_ERROR);
assert_eq!(client.responses.pending_len(), 1);
assert_eq!(
first
.try_response()
.expect_err("encoding failure wakes only its owner")
.message,
error.message
);
assert!(
second
.try_response()
.expect("sibling waiter remains valid")
.is_none()
);
assert_eq!(
client
.responses
.route(JsonRpcResponse::success(second_id, serde_json::Value::Null,)),
ResponseRoute::Delivered
);
assert!(
second
.try_response()
.expect("sibling waiter remains valid")
.is_some()
);
}
#[test]
fn client_from_parts_accessors_and_request_counter() {
let client = make_closed_client(true);
assert!(client.is_initialized());
assert_eq!(client.server_info().name, "test-server");
let caps_json = serde_json::to_value(client.server_capabilities()).expect("caps json");
assert_eq!(caps_json, serde_json::json!({}));
assert_eq!(client.protocol_version(), PROTOCOL_VERSION);
assert_eq!(client.next_request_id().expect("request ID"), 2);
assert_eq!(client.next_request_id().expect("request ID"), 3);
}
#[test]
fn ensure_initialized_noop_when_already_initialized() {
let mut client = make_closed_client(true);
assert!(client.ensure_initialized().is_ok());
assert!(client.is_initialized());
}
#[test]
fn ensure_initialized_fails_for_uninitialized_closed_transport() {
let mut client = make_closed_client(false);
std::thread::sleep(Duration::from_millis(50));
let err = client
.ensure_initialized()
.expect_err("expected init failure");
assert_eq!(err.code, fastmcp_core::McpErrorCode::InternalError);
assert!(!client.is_initialized());
}
#[test]
fn initialization_failure_with_verified_cleanup_preserves_the_operation_error() {
let mut client = make_closed_client(false);
let error = client.record_initialization_failure(McpError::internal_error(
"deterministic initialization failure",
));
assert_eq!(error.message, "deterministic initialization failure");
assert!(!is_cleanup_unverified(&error));
let recorded = client
.initialization_error
.as_ref()
.expect("initialization failure is retained");
assert_eq!(recorded.code, error.code);
assert_eq!(recorded.message, error.message);
assert!(!is_cleanup_unverified(recorded));
}
#[test]
fn initialization_failure_with_cleanup_failure_is_marked_unverified() {
let mut client = make_closed_client(false);
client.cleanup_error = Some(McpError::internal_error(
"deterministic retained transport cleanup failure",
));
let error = client.record_initialization_failure(McpError::internal_error(
"deterministic initialization failure",
));
assert!(is_cleanup_unverified(&error));
assert!(error.message.contains("cleanup failed"));
let recorded = client
.initialization_error
.as_ref()
.expect("unverified initialization cleanup failure is retained");
assert_eq!(recorded.code, error.code);
assert_eq!(recorded.message, error.message);
assert!(is_cleanup_unverified(recorded));
}
#[test]
fn client_core_api_methods_error_cleanly_on_closed_transport() {
let mut client = make_closed_client(true);
std::thread::sleep(Duration::from_millis(50));
let _ = client.cancel_request(7i64, Some("stop".to_string()));
assert!(client.list_tools().is_err());
assert!(
client
.call_tool("echo", serde_json::json!({"text": "hi"}))
.is_err()
);
let mut progress_events: Vec<(f64, Option<f64>, Option<String>)> = Vec::new();
let mut on_progress = |p: f64, total: Option<f64>, msg: Option<&str>| {
progress_events.push((p, total, msg.map(ToString::to_string)));
};
assert!(
client
.call_tool_with_progress(
"echo",
serde_json::json!({"text": "hi"}),
&mut on_progress
)
.is_err()
);
assert_eq!(progress_events.len(), 0);
assert!(client.list_resources().is_err());
assert!(client.list_resource_templates().is_err());
assert!(client.set_log_level(LogLevel::Debug).is_err());
assert!(client.read_resource("resource://test").is_err());
assert!(client.list_prompts().is_err());
let mut args = HashMap::new();
args.insert("name".to_string(), "world".to_string());
assert!(client.get_prompt("greeting", args).is_err());
}
#[test]
fn close_handles_already_exited_subprocess() {
let mut client = make_closed_client(true);
std::thread::sleep(Duration::from_millis(50));
client.close().expect("client cleanup");
}
// ========================================
// Client::builder and Client::stdio error
// ========================================
#[test]
fn client_builder_returns_client_builder() {
let _builder = Client::builder();
// builder() is a convenience method for ClientBuilder::new()
}
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn client_sse_is_the_standalone_exact_legacy_http_constructor() {
let _: fn(CanonicalHttpUrl, CanonicalHttpUrl) -> Result<HttpClient, HttpClientError> =
Client::sse;
let _ = Client::sse_with_cx;
}
#[cfg(feature = "legacy-2024-11-05")]
#[test]
#[allow(clippy::err_expect)] // Client deliberately has no Debug surface
fn client_stdio_fails_for_nonexistent_command() {
let result = Client::stdio("definitely-not-a-real-command-xyz", &[]);
assert!(result.is_err());
let err = result.err().expect("should be error");
assert_eq!(err.code, fastmcp_core::McpErrorCode::InternalError);
assert!(err.message.contains("spawn"));
}
#[cfg(not(feature = "legacy-2024-11-05"))]
#[test]
fn feature_off_stdio_auto_refuses_before_command_resolution_or_spawn() {
let error = Client::stdio_with_cx(
"fastmcp-client-feature-off-must-not-spawn",
&[],
Cx::for_testing(),
)
.expect_err("the feature-off direct constructor attempts only its modern command");
assert_eq!(DEFAULT_STDIO_PROTOCOL_POLICY, ProtocolPolicy::ModernOnly);
assert_eq!(error.code, McpErrorCode::InternalError);
assert!(
error.message.contains("Failed to spawn subprocess"),
"the feature-off default is a direct modern connection attempt"
);
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_02_public_stdio_defaults_to_auto_and_retains_modern_selection() {
let script = modern_typed_call_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","content":[{"type":"text","text":"public auto modern"}],"isError":false}}"#,
);
let mut client = Client::stdio("sh", &["-c", script.as_str()])
.expect("the public stdio constructor completes its modern Auto probe");
assert_eq!(client.protocol_policy(), ProtocolPolicy::Auto);
assert_eq!(
client.selected_protocol_era(),
Some(ProtocolEra::Modern2026)
);
assert!(client.server_discovery().is_some());
assert!(matches!(
client
.call_tool_typed("echo", serde_json::json!({}))
.expect("the selected modern connection uses typed final-result dispatch"),
CoreResult::Final(FinalCoreResult::ToolsCall { .. })
));
client.close().expect("public modern client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_02_public_stdio_auto_reopens_one_fresh_exact_legacy_child() {
let script = auto_discovery_refusal_client_script(-32_601);
let mut client = Client::stdio("sh", &["-c", script.as_str()])
.expect("only a discovery MethodNotFound authorizes the fresh legacy child");
assert_eq!(client.protocol_policy(), ProtocolPolicy::Auto);
assert_eq!(
client.selected_protocol_era(),
Some(ProtocolEra::Legacy2024)
);
assert!(client.server_discovery().is_none());
client
.ping()
.expect("the fresh exact legacy child receives the historical request shape");
client
.close()
.expect("public legacy fallback client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_02_public_stdio_auto_rejects_one_non_method_not_found_refusal() {
// Only the discovery refusal code differs from the paired fallback
// positive. No second process may be used as a legacy replay path.
let script = auto_discovery_refusal_client_script(-32_602);
let error = match Client::stdio("sh", &["-c", script.as_str()]) {
Ok(_) => panic!("InvalidParams discovery refusal cannot authorize legacy fallback"),
Err(error) => error,
};
assert_eq!(error.code, McpErrorCode::InvalidParams);
}
#[test]
fn client_stdio_with_cx_fails_when_cancelled() {
let cx = Cx::for_request();
cx.set_cancel_requested(true);
let result = Client::stdio_with_protocol_plan_with_cx(
"echo",
&["hello"],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
cx,
);
// Cancellation is admitted before command resolution in every feature profile.
assert!(result.is_err());
}
// ========================================
// Uninitialized client accessors
// ========================================
#[test]
fn uninitialized_client_is_not_initialized() {
let client = make_closed_client(false);
assert!(!client.is_initialized());
}
#[test]
fn uninitialized_client_server_info_is_empty() {
let client = make_closed_client(false);
assert_eq!(client.server_info().name, "test-server");
assert_eq!(client.server_info().version, "1.0.0");
}
#[test]
fn uninitialized_client_request_id_starts_at_one() {
let client = make_closed_client(false);
assert_eq!(client.next_request_id().expect("request ID"), 1);
assert_eq!(client.next_request_id().expect("request ID"), 2);
}
#[test]
fn initialized_client_request_id_starts_at_two() {
let client = make_closed_client(true);
// from_parts starts at 2 because initialize used id 1
assert_eq!(client.next_request_id().expect("request ID"), 2);
assert_eq!(client.next_request_id().expect("request ID"), 3);
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn direct_stdio_initialization_consumes_id_one_before_ordinary_requests() {
let initialize_result = InitializeResult {
protocol_version: PROTOCOL_VERSION.to_string(),
capabilities: ServerCapabilities::default(),
server_info: ServerInfo {
name: "direct-path-test-server".to_string(),
version: "1.0.0".to_string(),
},
instructions: None,
};
let response = JsonRpcMessage::Response(JsonRpcResponse::success(
RequestId::Number(INITIALIZE_REQUEST_ID),
serde_json::to_value(initialize_result).expect("serialize initialize result"),
));
let response_line = serde_json::to_string(&response).expect("serialize response envelope");
assert!(
!response_line.contains('\''),
"the shell fixture requires a single-quote-free JSON line"
);
let script = format!("printf '%s\\n' '{response_line}'; exec sleep 2");
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("direct exact-legacy stdio initialization succeeds");
assert_eq!(
client
.next_request_id()
.expect("first post-initialize request ID"),
2,
"initialize ID 1 must never be reused"
);
client.close().expect("client cleanup");
}
#[test]
#[allow(clippy::err_expect)] // Client deliberately has no Debug surface
fn eager_initialization_uses_bounded_server_response_writes() {
// The direct child control path intentionally accepts only frames that
// fit the POSIX minimum atomic pipe-write bound. An oversized
// peer-initiated invalid-notification response therefore gives us a
// deterministic proof that eager initialization uses that path; the
// ordinary blocking transport send would accept this frame and merely
// wait for the scripted peer until the request deadline. The request
// ID stays within its protocol bound; the long method is what makes
// the correlated error response exceed the atomic capacity.
let request =
JsonRpcRequest::new(format!("notifications/{}", "x".repeat(600)), None, 7_i64);
let response = server_request_response(&request)
.expect("an ID-bearing notification-shaped method receives an error response");
let response_size = serde_json::to_vec(&response)
.expect("serialize the bounded-write response precondition")
.len()
.checked_add(1)
.expect("newline cannot overflow the response size");
assert!(
response_size > 512,
"fixture response must exceed the POSIX minimum atomic pipe-write bound"
);
let request = JsonRpcMessage::Request(request);
let request_line = serde_json::to_string(&request).expect("serialize server request");
assert!(
!request_line.contains('\''),
"the shell fixture requires a single-quote-free JSON line"
);
let script = format!("printf '%s\\n' '{request_line}'; exec sleep 2");
let result = ClientBuilder::new()
.request_timeout_policy(
RequestTimeoutPolicy::new(Duration::from_secs(1), Duration::from_secs(1)).unwrap(),
)
.connect_stdio_with_cx("sh", &["-c", script.as_str()], &Cx::for_request());
let error = result
.err()
.expect("oversized initialization control response must fail closed");
assert_eq!(error.code, McpErrorCode::InternalError);
assert_eq!(error.message, CONTROL_FRAME_CAPACITY_ERROR);
}
#[test]
fn request_id_allocator_fails_closed_before_wrap_or_reuse() {
let client = make_closed_client(true);
client
.next_id
.store(REQUEST_ID_EXHAUSTION_SENTINEL - 1, Ordering::SeqCst);
assert_eq!(
client.next_request_id().expect("last issuable request ID"),
REQUEST_ID_EXHAUSTION_SENTINEL - 1
);
let exhausted = client
.next_request_id()
.expect_err("sentinel and wrapped IDs must never be issued");
assert!(exhausted.message.contains("ID space exhausted"));
assert_eq!(
client.next_id.load(Ordering::SeqCst),
REQUEST_ID_EXHAUSTION_SENTINEL,
"exhaustion is permanent and cannot wrap back to a live ID"
);
}
// ========================================
// API methods on uninitialized client
// ========================================
#[test]
fn uninitialized_client_list_tools_fails_on_init() {
let mut client = make_closed_client(false);
std::thread::sleep(Duration::from_millis(50));
let err = client.list_tools().expect_err("should fail");
assert_eq!(err.code, fastmcp_core::McpErrorCode::InternalError);
}
#[test]
fn uninitialized_client_call_tool_fails_on_init() {
let mut client = make_closed_client(false);
std::thread::sleep(Duration::from_millis(50));
let err = client
.call_tool("echo", serde_json::json!({"text": "hi"}))
.expect_err("should fail");
assert_eq!(err.code, fastmcp_core::McpErrorCode::InternalError);
}
#[test]
fn uninitialized_client_list_resources_fails_on_init() {
let mut client = make_closed_client(false);
std::thread::sleep(Duration::from_millis(50));
assert!(client.list_resources().is_err());
}
#[test]
fn uninitialized_client_list_prompts_fails_on_init() {
let mut client = make_closed_client(false);
std::thread::sleep(Duration::from_millis(50));
assert!(client.list_prompts().is_err());
}
#[test]
fn failed_auto_initialization_is_terminal_for_the_connection() {
let mut client = make_closed_client(false);
std::thread::sleep(Duration::from_millis(50));
let first = client
.ensure_initialized()
.expect_err("closed child cannot initialize");
assert!(client.initialization_error.is_some());
assert!(client.child.is_none());
let second = client
.ensure_initialized()
.expect_err("terminal failure must not retry initialize");
assert_eq!(second.code, first.code);
assert_eq!(second.message, first.message);
}
#[test]
fn uninitialized_client_cannot_send_cancellation_before_lifecycle_ack() {
let mut client = make_closed_client(false);
std::thread::sleep(Duration::from_millis(50));
let error = client
.cancel_request(99_i64, None)
.expect_err("cancellation must initialize the session first");
assert_eq!(error.code, fastmcp_core::McpErrorCode::InternalError);
assert!(!client.is_initialized());
}
#[cfg(unix)]
fn modern_public_client_script(discovery_response: &str) -> String {
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*) ;; *) exit 1 ;; esac; \
case \"$first\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r second || exit 1; \
case \"$second\" in *tools/list*) ;; *) exit 1 ;; esac; \
case \"$second\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
fn modern_log_level_absence_client_script() -> String {
let discovery_response =
modern_discovery_response("logging-metadata-modern-server", &[MODERN_PROTOCOL_VERSION]);
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*) ;; *) exit 1 ;; esac; \
case \"$first\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *tools/list*) ;; *) exit 1 ;; esac; \
case \"$request\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) ;; *) exit 1 ;; esac; \
case \"$request\" in *io.modelcontextprotocol/logLevel*) exit 1 ;; \
*) printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
fn modern_log_level_metadata_client_script() -> String {
let discovery_response =
modern_discovery_response("logging-metadata-modern-server", &[MODERN_PROTOCOL_VERSION]);
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*) ;; *) exit 1 ;; esac; \
case \"$first\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *tools/list*) ;; *) exit 1 ;; esac; \
case \"$request\" in *'\"io.modelcontextprotocol/logLevel\":\"notice\"'*) ;; *) exit 1 ;; esac; \
case \"$request\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
fn modern_typed_call_client_script(call_response: &str) -> String {
let discovery_response =
modern_discovery_response("typed-modern-server", &[MODERN_PROTOCOL_VERSION]);
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*) ;; *) exit 1 ;; esac; \
case \"$first\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r second || exit 1; \
case \"$second\" in *tools/call*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{call_response}' ;; *) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
fn modern_final_convenience_client_script(method: &str, response: &str) -> String {
let discovery_response = modern_discovery_response(
"final-convenience-modern-server",
&[MODERN_PROTOCOL_VERSION],
);
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*) ;; *) exit 1 ;; esac; \
case \"$first\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r second || exit 1; \
case \"$second\" in *{method}*) ;; *) exit 1 ;; esac; \
case \"$second\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{response}' ;; *) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
fn modern_mrtr_retry_client_script(method: &str, complete_result: &str) -> String {
let discovery_response =
modern_discovery_response("mrtr-retry-modern-server", &[MODERN_PROTOCOL_VERSION]);
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*) ;; *) exit 1 ;; esac; \
case \"$first\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r initial || exit 1; \
case \"$initial\" in *{method}*) ;; *) exit 1 ;; esac; \
case \"$initial\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"input_required\",\"inputRequests\":{{\"roots\":{{\"method\":\"roots/list\"}}}},\"requestState\":\"retry-1\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r retry || exit 1; \
case \"$retry\" in *{method}*) ;; *) exit 1 ;; esac; \
case \"$retry\" in *'\"inputResponses\":{{\"roots\":{{\"roots\":[]}}}}'*) ;; *) exit 1 ;; esac; \
case \"$retry\" in *'\"requestState\":\"retry-1\"'*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{complete_result}}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
fn modern_mrtr_two_input_client_script(complete_retry: bool) -> String {
let discovery_response =
modern_discovery_response("mrtr-two-input-modern-server", &[MODERN_PROTOCOL_VERSION]);
let retry = if complete_retry {
"IFS= read -r retry || exit 1; \
case \"$retry\" in *tools/call*'\"inputResponses\":{\"roots\":{\"roots\":[]},\"sampling\":{\"messages\":[]}}'*'\"requestState\":\"retry-two\"'*) \
printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{\"resultType\":\"complete\",\"content\":[],\"isError\":false}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
} else {
"IFS= read -r retry && exit 1; exit 0"
};
format!(
"IFS= read -r discovery || exit 1; \
case \"$discovery\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r initial || exit 1; \
case \"$initial\" in *tools/call*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"input_required\",\"inputRequests\":{{\"roots\":{{\"method\":\"roots/list\"}},\"sampling\":{{\"method\":\"sampling/createMessage\"}}}},\"requestState\":\"retry-two\"}}}}' ;; *) exit 1 ;; esac; \
{retry}"
)
}
#[cfg(unix)]
fn modern_mrtr_multi_round_client_script() -> String {
let discovery_response =
modern_discovery_response("mrtr-multi-round-modern-server", &[MODERN_PROTOCOL_VERSION]);
format!(
"IFS= read -r discover || exit 1; \
case \"$discover\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r initial || exit 1; \
case \"$initial\" in *tools/call*'\"arguments\":{{\"round\":1}}'*'\"name\":\"retry-tool\"'*'\"id\":2'*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"input_required\",\"inputRequests\":{{\"roots\":{{\"method\":\"roots/list\"}}}},\"requestState\":\"retry-1\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r first_retry || exit 1; \
case \"$first_retry\" in *tools/call*'\"arguments\":{{\"round\":1}}'*'\"inputResponses\":{{\"roots\":{{\"roots\":[]}}}}'*'\"name\":\"retry-tool\"'*'\"requestState\":\"retry-1\"'*'\"id\":3'*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{{\"resultType\":\"input_required\",\"requestState\":\"retry-2\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r state_only_retry || exit 1; \
case \"$state_only_retry\" in *'\"inputResponses\"'*) exit 1 ;; *tools/call*'\"arguments\":{{\"round\":1}}'*'\"name\":\"retry-tool\"'*'\"requestState\":\"retry-2\"'*'\"id\":4'*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":4,\"result\":{{\"resultType\":\"complete\",\"content\":[],\"isError\":false}}}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
fn modern_mrtr_round_bound_client_script() -> String {
let discovery_response =
modern_discovery_response("mrtr-round-bound-modern-server", &[MODERN_PROTOCOL_VERSION]);
let mut rounds = String::new();
for request_id in 2..=(MAX_MRTR_CONTINUATION_ROUNDS + 2) {
let continuation_response = format!(
r#"{{"jsonrpc":"2.0","id":{request_id},"result":{{"resultType":"input_required","inputRequests":{{"roots":{{"method":"roots/list"}}}},"requestState":"retry-bound"}}}}"#,
);
let expected_input_responses = if request_id == 2 {
""
} else {
"*'\"inputResponses\":{\"roots\":{\"roots\":[]}}'"
};
rounds.push_str(&format!(
"IFS= read -r request || exit 1; \
case \"$request\" in *tools/call*'\"arguments\":{{\"round\":1}}'{expected_input_responses}*'\"name\":\"retry-tool\"'*'\"id\":{request_id}'*) ;; *) exit 1 ;; esac; \
printf '%s\\n' '{continuation_response}'; \
"
));
}
format!(
"IFS= read -r discover || exit 1; \
case \"$discover\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
{rounds}exec sleep 2"
)
}
#[cfg(unix)]
fn modern_progress_client_script(call_response: &str) -> String {
let discovery_response =
modern_discovery_response("progress-modern-server", &[MODERN_PROTOCOL_VERSION]);
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*) ;; *) exit 1 ;; esac; \
case \"$first\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *tools/call*) ;; *) exit 1 ;; esac; \
case \"$request\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) ;; *) exit 1 ;; esac; \
case \"$request\" in *'\"progressToken\":2'*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{{\"progressToken\":2,\"progress\":0.5,\"total\":1.0,\"message\":\"modern progress\"}}}}'; \
printf '%s\\n' '{call_response}' ;; *) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
fn modern_server_notification_client_script(notification: &str, call_response: &str) -> String {
let discovery_response =
modern_discovery_response("notification-modern-server", &[MODERN_PROTOCOL_VERSION]);
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *tools/call*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{notification}'; \
printf '%s\\n' '{call_response}' ;; *) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
fn modern_reverse_ping_client_script() -> String {
let discovery_response =
modern_discovery_response("reverse-ping-modern-server", &[MODERN_PROTOCOL_VERSION]);
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r call || exit 1; \
case \"$call\" in *tools/call*) ;; *) exit 1 ;; esac; \
case \"$call\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) ;; *) exit 1 ;; esac; \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":\"server-ping\",\"method\":\"ping\"}}'; \
IFS= read -r reverse_response || exit 1; \
case \"$reverse_response\" in *'\"code\":-32601'*) ;; *) exit 1 ;; esac; \
case \"$reverse_response\" in *'\"id\":\"server-ping\"'*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"content\":[{{\"type\":\"text\",\"text\":\"reverse ping rejected\"}}],\"isError\":false}}}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
fn modern_subscriptions_listen_client_script(
acknowledgement_subscription_id: i64,
stream_frames: &[&str],
) -> String {
let discovery_response =
modern_discovery_response("subscriptions-modern-server", &[MODERN_PROTOCOL_VERSION]);
let acknowledgement = format!(
r#"{{"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{{"_meta":{{"io.modelcontextprotocol/subscriptionId":{acknowledgement_subscription_id}}},"notifications":{{"toolsListChanged":true}}}}}}"#
);
let stream_frames = stream_frames
.iter()
.map(|frame| format!("printf '%s\\n' '{frame}'"))
.collect::<Vec<_>>()
.join("; ");
let stream_frames = if stream_frames.is_empty() {
String::new()
} else {
format!("; {stream_frames}")
};
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*) ;; *) exit 1 ;; esac; \
case \"$first\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *subscriptions/listen*) ;; *) exit 1 ;; esac; \
case \"$request\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) ;; *) exit 1 ;; esac; \
case \"$request\" in *'\"toolsListChanged\":true'*) \
printf '%s\\n' '{acknowledgement}'{stream_frames} ;; *) exit 1 ;; esac"
)
}
#[cfg(unix)]
fn modern_incremental_catalog_listener_with_tool_call_script() -> String {
let discovery_response = modern_discovery_response(
"incremental-catalog-listener-server",
&[MODERN_PROTOCOL_VERSION],
);
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*) ;; *) exit 1 ;; esac; \
case \"$first\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r listen || exit 1; \
case \"$listen\" in *subscriptions/listen*) ;; *) exit 1 ;; esac; \
case \"$listen\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) ;; *) exit 1 ;; esac; \
case \"$listen\" in *'\"toolsListChanged\":true'*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"method\":\"notifications/subscriptions/acknowledged\",\"params\":{{\"_meta\":{{\"io.modelcontextprotocol/subscriptionId\":2}},\"notifications\":{{\"toolsListChanged\":true}}}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r call || exit 1; \
case \"$call\" in *tools/call*) ;; *) exit 1 ;; esac; \
case \"$call\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}}'; \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{{\"resultType\":\"complete\",\"content\":[{{\"type\":\"text\",\"text\":\"hidden\"}}],\"isError\":false}}}}'; \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"_meta\":{{\"io.modelcontextprotocol/subscriptionId\":2}}}}}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
#[cfg(feature = "tasks")]
fn modern_tasks_subscriptions_listen_client_script(
task_id: &str,
notification_task_id: &str,
) -> String {
let discovery_response = modern_tasks_discovery_response(
"tasks-subscriptions-modern-server",
serde_json::json!({}),
);
let acknowledgement = format!(
r#"{{"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{{"_meta":{{"io.modelcontextprotocol/subscriptionId":2}},"notifications":{{"toolsListChanged":true,"taskIds":["{task_id}"]}}}}}}"#
);
let task_notification = format!(
r#"{{"jsonrpc":"2.0","method":"notifications/tasks","params":{{"_meta":{{"io.modelcontextprotocol/subscriptionId":2}},"taskId":"{notification_task_id}","status":"working","createdAt":"2026-07-28T12:00:00.000Z","lastUpdatedAt":"2026-07-28T12:00:00.000Z","ttlMs":null}}}}"#
);
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *subscriptions/listen*) ;; *) exit 1 ;; esac; \
case \"$request\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) ;; *) exit 1 ;; esac; \
case \"$request\" in *'\"toolsListChanged\":true'*) ;; *) exit 1 ;; esac; \
case \"$request\" in *'\"taskIds\":[\"{task_id}\"]'*) ;; *) exit 1 ;; esac; \
case \"$request\" in *'\"extensions\":{{\"io.modelcontextprotocol/tasks\":{{}}}}'*) ;; *) exit 1 ;; esac; \
printf '%s\\n' '{acknowledgement}'; \
printf '%s\\n' '{task_notification}'; \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"_meta\":{{\"io.modelcontextprotocol/subscriptionId\":2}}}}}}'"
)
}
#[cfg(unix)]
#[cfg(feature = "tasks")]
fn modern_incremental_tasks_listener_client_script(
requested_task_id: &str,
notification_task_id: &str,
subscription_id: i64,
) -> String {
let discovery_response = modern_tasks_discovery_response(
"incremental-tasks-listener-server",
serde_json::json!({}),
);
let acknowledgement = format!(
r#"{{"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{{"_meta":{{"io.modelcontextprotocol/subscriptionId":{subscription_id}}},"notifications":{{"taskIds":["{requested_task_id}"]}}}}}}"#
);
let task_notification = format!(
r#"{{"jsonrpc":"2.0","method":"notifications/tasks","params":{{"_meta":{{"io.modelcontextprotocol/subscriptionId":{subscription_id}}},"taskId":"{notification_task_id}","status":"working","createdAt":"2026-07-28T12:00:00.000Z","lastUpdatedAt":"2026-07-28T12:00:00.000Z","ttlMs":null}}}}"#
);
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *subscriptions/listen*io.modelcontextprotocol/protocolVersion*2026-07-28*'\\\"taskIds\\\":[\\\"{requested_task_id}\\\"]'*) ;; *) exit 1 ;; esac; \
printf '%s\\n' '{acknowledgement}'; \
printf '%s\\n' '{task_notification}'; \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"_meta\":{{\"io.modelcontextprotocol/subscriptionId\":2}}}}}}'; \
exec sleep 2"
)
}
#[cfg(unix)]
#[cfg(feature = "tasks")]
fn live_tasks_listener_fixture(
requested_task_id: &str,
notification_task_id: &str,
subscription_id: i64,
) -> String {
let discovery_response =
modern_tasks_discovery_response("live-tasks-listener-server", serde_json::json!({}));
let acknowledgement = format!(
r#"{{"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{{"_meta":{{"io.modelcontextprotocol/subscriptionId":{subscription_id}}},"notifications":{{"taskIds":["{requested_task_id}"]}}}}}}"#
);
let task_notification = format!(
r#"{{"jsonrpc":"2.0","method":"notifications/tasks","params":{{"_meta":{{"io.modelcontextprotocol/subscriptionId":{subscription_id}}},"taskId":"{notification_task_id}","status":"working","createdAt":"2026-07-28T12:00:00.000Z","lastUpdatedAt":"2026-07-28T12:00:00.000Z","ttlMs":null}}}}"#
);
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *subscriptions/listen*) \
printf '%s\\n' '{acknowledgement}'; \
printf '%s\\n' '{task_notification}'; \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"_meta\":{{\"io.modelcontextprotocol/subscriptionId\":2}}}}}}' \
;; *) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
fn modern_subscription_cancellation_late_terminal_client_script() -> String {
let discovery_response = modern_discovery_response(
"subscriptions-cancellation-modern-server",
&[MODERN_PROTOCOL_VERSION],
);
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*) ;; *) exit 1 ;; esac; \
case \"$first\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r listen || exit 1; \
case \"$listen\" in *subscriptions/listen*) ;; *) exit 1 ;; esac; \
case \"$listen\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) ;; *) exit 1 ;; esac; \
case \"$listen\" in *'\"toolsListChanged\":true'*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"method\":\"notifications/subscriptions/acknowledged\",\"params\":{{\"_meta\":{{\"io.modelcontextprotocol/subscriptionId\":2}},\"notifications\":{{\"toolsListChanged\":true}}}}}}'; \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\",\"params\":{{\"requestId\":2}}}}'; \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"_meta\":{{\"io.modelcontextprotocol/subscriptionId\":2}}}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r tools || exit 1; \
case \"$tools\" in *tools/list*) ;; *) exit 1 ;; esac; \
case \"$tools\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
fn modern_typed_list_client_script(list_response: &str) -> String {
let discovery_response =
modern_discovery_response("typed-list-modern-server", &[MODERN_PROTOCOL_VERSION]);
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*) ;; *) exit 1 ;; esac; \
case \"$first\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r second || exit 1; \
case \"$second\" in *tools/list*) ;; *) exit 1 ;; esac; \
case \"$second\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{list_response}' ;; *) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
fn modern_remaining_core_client_script() -> String {
let discovery_response =
modern_discovery_response("remaining-core-modern-server", &[MODERN_PROTOCOL_VERSION]);
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*) ;; *) exit 1 ;; esac; \
case \"$first\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r tools || exit 1; \
case \"$tools\" in *tools/list*) ;; *) exit 1 ;; esac; \
case \"$tools\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r resources || exit 1; \
case \"$resources\" in *resources/list*) ;; *) exit 1 ;; esac; \
case \"$resources\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{{\"resultType\":\"complete\",\"resources\":[],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r templates || exit 1; \
case \"$templates\" in *resources/templates/list*) ;; *) exit 1 ;; esac; \
case \"$templates\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":4,\"result\":{{\"resultType\":\"complete\",\"resourceTemplates\":[],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r read_resource || exit 1; \
case \"$read_resource\" in *resources/read*) ;; *) exit 1 ;; esac; \
case \"$read_resource\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":5,\"result\":{{\"resultType\":\"complete\",\"contents\":[],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r prompts || exit 1; \
case \"$prompts\" in *prompts/list*) ;; *) exit 1 ;; esac; \
case \"$prompts\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":6,\"result\":{{\"resultType\":\"complete\",\"prompts\":[],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r get_prompt || exit 1; \
case \"$get_prompt\" in *prompts/get*) ;; *) exit 1 ;; esac; \
case \"$get_prompt\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{{\"resultType\":\"complete\",\"messages\":[]}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r tools_after_config || exit 1; \
case \"$tools_after_config\" in *tools/list*) ;; *) exit 1 ;; esac; \
case \"$tools_after_config\" in *'\"io.modelcontextprotocol/logLevel\":\"notice\"'*) ;; *) exit 1 ;; esac; \
case \"$tools_after_config\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":8,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
fn modern_completion_client_script(completion_response: &str) -> String {
let discovery_response =
modern_discovery_response("completion-modern-server", &[MODERN_PROTOCOL_VERSION]);
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r second || exit 1; \
case \"$second\" in *completion/complete*) ;; *) exit 1 ;; esac; \
case \"$second\" in *io.modelcontextprotocol/protocolVersion*2026-07-28*) ;; *) exit 1 ;; esac; \
case \"$second\" in *'\"context\":{{\"arguments\":{{\"region\":\"us-east-1\"}}}}'*) ;; *) exit 1 ;; esac; \
case \"$second\" in *'\"title\":\"Deploy\"'*) \
printf '%s\\n' '{completion_response}' ;; *) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
fn modern_discovery_response(server_name: &str, supported_versions: &[&str]) -> String {
let capabilities = fastmcp_protocol::ServerDiscoverCapabilities::from_registry(
&fastmcp_protocol::ServerBehaviorRegistry::default(),
std::collections::BTreeMap::new(),
)
.expect("an empty installed behavior registry is discoverable");
let result = ServerDiscoverResult::new(
capabilities,
ServerInfo {
name: server_name.to_owned(),
version: "1.0.0".to_owned(),
},
None,
fastmcp_protocol::DiscoveryCacheHints::private_ttl_ms(0),
);
let mut response = serde_json::json!({
"jsonrpc": JSONRPC_VERSION,
"id": 1,
"result": result,
});
response["result"]["supportedVersions"] = serde_json::json!(supported_versions);
serde_json::to_string(&response)
.expect("typed modern discovery response serializes deterministically")
}
#[cfg(unix)]
#[cfg(feature = "tasks")]
fn modern_tasks_discovery_response(server_name: &str, settings: serde_json::Value) -> String {
let capabilities = fastmcp_protocol::ServerDiscoverCapabilities::from_registry(
&fastmcp_protocol::ServerBehaviorRegistry::default(),
std::collections::BTreeMap::from([(
fastmcp_protocol::TASKS_EXTENSION.to_owned(),
settings,
)]),
)
.expect("Tasks discovery settings satisfy the generic extension envelope");
let result = ServerDiscoverResult::new(
capabilities,
ServerInfo {
name: server_name.to_owned(),
version: "1.0.0".to_owned(),
},
None,
fastmcp_protocol::DiscoveryCacheHints::private_ttl_ms(0),
);
let mut response = serde_json::json!({
"jsonrpc": JSONRPC_VERSION,
"id": 1,
"result": result,
});
response["result"]["supportedVersions"] = serde_json::json!([MODERN_PROTOCOL_VERSION]);
serde_json::to_string(&response)
.expect("Tasks discovery response serializes deterministically")
}
#[cfg(unix)]
fn raw_extension_configuration(
direction: ExtensionDirection,
fallback: fastmcp_protocol::extensions::ExtensionFallbackPolicy,
) -> (
fastmcp_protocol::ExtensionId,
fastmcp_protocol::extensions::ExtensionDescriptorRegistry,
fastmcp_protocol::extensions::ClientExtensionDiscovery,
) {
use fastmcp_protocol::extensions::{
ClientExtensionDiscovery, ExtensionDescriptor, ExtensionMethodDescriptor,
ExtensionNegotiationResolver, ExtensionSettings, ExtensionSettingsSchema,
};
let extension_id = fastmcp_protocol::ExtensionId::parse("com.example/raw")
.expect("test extension identifier is valid");
let mut registry = fastmcp_protocol::extensions::ExtensionDescriptorRegistry::new();
registry
.register(ExtensionDescriptor {
id: extension_id.clone(),
client_settings: ExtensionSettingsSchema {
schema_id: "raw-client-settings-v1".to_owned(),
codec_id: "raw-client-codec-v1".to_owned(),
},
server_settings: ExtensionSettingsSchema {
schema_id: "raw-server-settings-v1".to_owned(),
codec_id: "raw-server-codec-v1".to_owned(),
},
resolver: ExtensionNegotiationResolver {
id: "raw-settings-v1".to_owned(),
version: 1,
fallback,
},
method: Some(ExtensionMethodDescriptor {
name: "example/echo".to_owned(),
direction,
http_era_disposition: (direction == ExtensionDirection::ClientToServer)
.then_some(
fastmcp_protocol::extensions::ExtensionHttpEraDisposition::ModernExclusive,
),
legacy_fallback: false,
}),
notification: None,
result_discriminator: None,
routing_headers: Vec::new(),
stdio_correlation: None,
})
.expect("test extension descriptor is valid");
let settings = ExtensionSettings::new(serde_json::json!({ "mode": "raw" }))
.expect("test extension settings are an object");
let discovery = ClientExtensionDiscovery {
extensions: BTreeMap::from([(extension_id.clone(), settings)]),
};
(extension_id, registry, discovery)
}
/// Resolver canary that succeeds exactly once per resolver instance.
///
/// The builder runtime must construct this state for each connection and
/// discovery retry. Sharing it would make the second negotiation fail.
#[cfg(unix)]
#[derive(Default)]
struct OneShotRawExtensionResolver {
has_resolved: bool,
}
#[cfg(unix)]
impl fastmcp_protocol::extensions::ExtensionSettingsCompatibilityResolver
for OneShotRawExtensionResolver
{
fn resolve(
&mut self,
descriptor: &fastmcp_protocol::extensions::ExtensionDescriptor,
client: &fastmcp_protocol::extensions::ExtensionSettings,
_server: &fastmcp_protocol::extensions::ExtensionSettings,
) -> Result<
fastmcp_protocol::extensions::ExtensionSettings,
fastmcp_protocol::extensions::ExtensionNegotiationError,
> {
if std::mem::replace(&mut self.has_resolved, true) {
return Err(
fastmcp_protocol::extensions::ExtensionNegotiationError::SettingsCompatibilityRejected(
descriptor.id.to_string(),
),
);
}
Ok(client.clone())
}
}
/// Deliberately shared resolver state used to prove that the explicit
/// factory contract cannot turn an `Arc<Mutex<_>>` clone into isolation.
#[cfg(unix)]
struct SharedArcRawExtensionResolver {
shared: Arc<Mutex<OneShotRawExtensionResolver>>,
}
#[cfg(unix)]
impl fastmcp_protocol::extensions::ExtensionSettingsCompatibilityResolver
for SharedArcRawExtensionResolver
{
fn resolve(
&mut self,
descriptor: &fastmcp_protocol::extensions::ExtensionDescriptor,
client: &fastmcp_protocol::extensions::ExtensionSettings,
server: &fastmcp_protocol::extensions::ExtensionSettings,
) -> Result<
fastmcp_protocol::extensions::ExtensionSettings,
fastmcp_protocol::extensions::ExtensionNegotiationError,
> {
let mut shared = self
.shared
.lock()
.expect("shared resolver canary mutex is not poisoned");
fastmcp_protocol::extensions::ExtensionSettingsCompatibilityResolver::resolve(
&mut *shared,
descriptor,
client,
server,
)
}
}
#[cfg(unix)]
// The registry callback contract must return `Result` so tests can substitute rejecting
// resolvers; this accepting test implementation intentionally always selects `Ok`.
#[allow(clippy::unnecessary_wraps)]
fn accept_raw_extension_settings(
_descriptor: &fastmcp_protocol::extensions::ExtensionDescriptor,
client: &fastmcp_protocol::extensions::ExtensionSettings,
_server: &fastmcp_protocol::extensions::ExtensionSettings,
) -> Result<
fastmcp_protocol::extensions::ExtensionSettings,
fastmcp_protocol::extensions::ExtensionNegotiationError,
> {
Ok(client.clone())
}
#[cfg(unix)]
fn modern_raw_extension_discovery_response(
server_name: &str,
server_settings: Option<serde_json::Value>,
) -> String {
let extensions = server_settings.map_or_else(BTreeMap::new, |settings| {
BTreeMap::from([("com.example/raw".to_owned(), settings)])
});
let capabilities = fastmcp_protocol::ServerDiscoverCapabilities::from_registry(
&fastmcp_protocol::ServerBehaviorRegistry::default(),
extensions,
)
.expect("raw extension discovery settings satisfy the generic envelope");
let result = ServerDiscoverResult::new(
capabilities,
ServerInfo {
name: server_name.to_owned(),
version: "1.0.0".to_owned(),
},
None,
fastmcp_protocol::DiscoveryCacheHints::private_ttl_ms(0),
);
serde_json::to_string(&serde_json::json!({
"jsonrpc": JSONRPC_VERSION,
"id": 1,
"result": result,
}))
.expect("raw extension discovery response serializes")
}
#[cfg(unix)]
fn modern_raw_extension_client_script(response: &str) -> String {
let discovery = modern_raw_extension_discovery_response(
"raw-extension-server",
Some(serde_json::json!({ "mode": "raw" })),
);
format!(
"IFS= read -r discovery || exit 1; \\
case \"$discovery\" in *server/discover*'\"extensions\":{{\"com.example/raw\":{{\"mode\":\"raw\"}}}}'*) \\
printf '%s\\n' '{discovery}' ;; *) exit 1 ;; esac; \\
IFS= read -r request || exit 1; \\
case \"$request\" in *'\"method\":\"example/echo\"'*'\"extensions\":{{\"com.example/raw\":{{\"mode\":\"raw\"}}}}'*'\"input\":\"ok\"'*'\"id\":2'*) \\
printf '%s\\n' '{response}' ;; *) exit 1 ;; esac; \\
exec sleep 2"
)
}
#[cfg(unix)]
fn modern_raw_extension_no_contact_client_script(
server_settings: Option<serde_json::Value>,
) -> String {
let discovery =
modern_raw_extension_discovery_response("raw-extension-server", server_settings);
format!(
"IFS= read -r discovery || exit 1; \\
case \"$discovery\" in *server/discover*'\"extensions\":{{\"com.example/raw\":{{\"mode\":\"raw\"}}}}'*) \\
printf '%s\\n' '{discovery}' ;; *) exit 1 ;; esac; \\
if IFS= read -r unexpected; then exit 1; fi; \\
exit 0"
)
}
#[cfg(unix)]
fn legacy_raw_extension_no_contact_client_script() -> &'static str {
"IFS= read -r initialize || exit 1; \\
case \"$initialize\" in *'\"method\":\"initialize\"'*) \\
printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"serverInfo\":{\"name\":\"legacy-raw-extension\",\"version\":\"1.0.0\"}}}' ;; *) exit 1 ;; esac; \\
IFS= read -r lifecycle || exit 1; \\
case \"$lifecycle\" in *notifications/initialized*) ;; *) exit 1 ;; esac; \\
if IFS= read -r unexpected; then exit 1; fi; \\
exit 0"
}
#[cfg(unix)]
#[cfg(feature = "tasks")]
fn modern_final_tasks_client_script(get_response: &str) -> String {
let discovery_response =
modern_tasks_discovery_response("tasks-modern-server", serde_json::json!({}));
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r get || exit 1; \
case \"$get\" in *'\"method\":\"tasks/get\"'*) ;; *) exit 1 ;; esac; \
case \"$get\" in *'\"taskId\":\"task-1\"'*) ;; *) exit 1 ;; esac; \
case \"$get\" in *'\"extensions\":{{\"io.modelcontextprotocol/tasks\":{{}}}}'*) \
printf '%s\\n' '{get_response}' ;; *) exit 1 ;; esac; \
IFS= read -r update || exit 1; \
case \"$update\" in *'\"method\":\"tasks/update\"'*) ;; *) exit 1 ;; esac; \
case \"$update\" in *'\"taskId\":\"task-1\"'*) ;; *) exit 1 ;; esac; \
case \"$update\" in *'\"inputResponses\":{{}}'*) ;; *) exit 1 ;; esac; \
case \"$update\" in *'\"extensions\":{{\"io.modelcontextprotocol/tasks\":{{}}}}'*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{{\"resultType\":\"complete\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r cancel || exit 1; \
case \"$cancel\" in *'\"method\":\"tasks/cancel\"'*) ;; *) exit 1 ;; esac; \
case \"$cancel\" in *'\"taskId\":\"task-1\"'*) ;; *) exit 1 ;; esac; \
case \"$cancel\" in *'\"extensions\":{{\"io.modelcontextprotocol/tasks\":{{}}}}'*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":4,\"result\":{{\"resultType\":\"complete\"}}}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
#[cfg(feature = "tasks")]
fn modern_final_tasks_get_client_script(get_response: &str) -> String {
let discovery_response =
modern_tasks_discovery_response("tasks-modern-server", serde_json::json!({}));
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r get || exit 1; \
case \"$get\" in *'\"method\":\"tasks/get\"'*) ;; *) exit 1 ;; esac; \
case \"$get\" in *'\"taskId\":\"task-1\"'*) ;; *) exit 1 ;; esac; \
case \"$get\" in *'\"extensions\":{{\"io.modelcontextprotocol/tasks\":{{}}}}'*) \
printf '%s\\n' '{get_response}' ;; *) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
#[cfg(feature = "tasks")]
fn modern_final_tool_task_client_script(response: &str) -> String {
let discovery_response =
modern_tasks_discovery_response("tool-task-modern-server", serde_json::json!({}));
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r call || exit 1; \
case \"$call\" in *'\"method\":\"tools/call\"'*) ;; *) exit 1 ;; esac; \
case \"$call\" in *'\"name\":\"durable-tool\"'*) ;; *) exit 1 ;; esac; \
case \"$call\" in *'\"extensions\":{{\"io.modelcontextprotocol/tasks\":{{}}}}'*) ;; *) exit 1 ;; esac; \
printf '%s\\n' '{response}'; \
exec sleep 2"
)
}
#[cfg(unix)]
fn modern_discovery_response_with_final_state(server_name: &str, cache_scope: &str) -> String {
let capabilities = fastmcp_protocol::ServerDiscoverCapabilities::from_registry(
&fastmcp_protocol::ServerBehaviorRegistry::from_behaviors([
fastmcp_protocol::ServerBehavior::ToolsList,
fastmcp_protocol::ServerBehavior::ToolsListChangedNotification,
]),
std::collections::BTreeMap::from([(
"com.example/session-state".to_owned(),
serde_json::json!({ "mode": "lossless" }),
)]),
)
.expect("the installed final behavior registry is discoverable");
let instructions = fastmcp_protocol::ServerInstructions::new("use the final contract")
.expect("bounded test instructions are admitted");
let result = ServerDiscoverResult::new(
capabilities,
ServerInfo {
name: server_name.to_owned(),
version: "1.0.0".to_owned(),
},
Some(instructions),
fastmcp_protocol::DiscoveryCacheHints::private_ttl_ms(73),
);
let mut result = serde_json::to_value(result)
.expect("typed modern discovery result serializes deterministically");
result["_meta"]["com.example/session-state"] = serde_json::json!({ "origin": "peer" });
result["cacheScope"] = serde_json::json!(cache_scope);
serde_json::to_string(&serde_json::json!({
"jsonrpc": JSONRPC_VERSION,
"id": 1,
"result": result,
}))
.expect("modern discovery response serializes deterministically")
}
#[cfg(unix)]
fn legacy_public_client_script() -> &'static str {
"IFS= read -r first || exit 1; \
case \"$first\" in *initialize*) ;; *) exit 1 ;; esac; \
case \"$first\" in *2024-11-05*) \
printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"serverInfo\":{\"name\":\"legacy-server\",\"version\":\"1.0.0\"}}}' ;; *) exit 1 ;; esac; \
IFS= read -r lifecycle || exit 1; \
case \"$lifecycle\" in *notifications/initialized*) ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *io.modelcontextprotocol/protocolVersion*|*io.modelcontextprotocol/clientCapabilities*) exit 1 ;; *) ;; esac; \
case \"$request\" in *ping*) printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
}
#[cfg(unix)]
fn auto_discovery_refusal_client_script(refusal_code: i32) -> String {
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in \
*server/discover*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{{\"code\":{refusal_code},\"message\":\"discovery refusal\"}}}}' ;; \
*initialize*2024-11-05*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{{}},\"serverInfo\":{{\"name\":\"public-auto-legacy\",\"version\":\"1.0.0\"}}}}}}'; \
IFS= read -r lifecycle || exit 1; \
case \"$lifecycle\" in *notifications/initialized*) ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *ping*io.modelcontextprotocol/protocolVersion*|*ping*io.modelcontextprotocol/clientCapabilities*) exit 1 ;; \
*ping*) printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{}}}}' ;; *) exit 1 ;; esac ;; \
*) exit 1 ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
fn legacy_reverse_ping_client_script() -> &'static str {
"IFS= read -r first || exit 1; \
case \"$first\" in *initialize*2024-11-05*) \
printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"serverInfo\":{\"name\":\"legacy-reverse-ping-server\",\"version\":\"1.0.0\"}}}' ;; *) exit 1 ;; esac; \
IFS= read -r lifecycle || exit 1; \
case \"$lifecycle\" in *notifications/initialized*) ;; *) exit 1 ;; esac; \
IFS= read -r client_ping || exit 1; \
case \"$client_ping\" in *ping*io.modelcontextprotocol/*|*ping*io.modelcontextprotocol/clientCapabilities*) exit 1 ;; \
*ping*) printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":\"server-ping\",\"method\":\"ping\"}'; ;; *) exit 1 ;; esac; \
IFS= read -r reverse_response || exit 1; \
case \"$reverse_response\" in *'\"id\":\"server-ping\"'*) ;; *) exit 1 ;; esac; \
case \"$reverse_response\" in *'\"result\":{}'*) \
printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
}
#[cfg(unix)]
fn legacy_resource_subscription_client_script() -> &'static str {
"IFS= read -r first || exit 1; \
case \"$first\" in *initialize*2024-11-05*) \
printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"serverInfo\":{\"name\":\"legacy-resource-subscriptions\",\"version\":\"1.0.0\"}}}' ;; *) exit 1 ;; esac; \
IFS= read -r lifecycle || exit 1; \
case \"$lifecycle\" in *notifications/initialized*) ;; *) exit 1 ;; esac; \
IFS= read -r subscribe || exit 1; \
case \"$subscribe\" in *resources/subscribe*'\"uri\":\"resource://test\"'*io.modelcontextprotocol/*) exit 1 ;; \
*resources/subscribe*'\"uri\":\"resource://test\"'*) printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{}}' ;; *) exit 1 ;; esac; \
IFS= read -r unsubscribe || exit 1; \
case \"$unsubscribe\" in *resources/unsubscribe*'\"uri\":\"resource://test\"'*io.modelcontextprotocol/*) exit 1 ;; \
*resources/unsubscribe*'\"uri\":\"resource://test\"'*) printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
}
#[cfg(unix)]
fn legacy_typed_call_client_script() -> &'static str {
"IFS= read -r first || exit 1; \
case \"$first\" in *initialize*2024-11-05*) \
printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"serverInfo\":{\"name\":\"legacy-server\",\"version\":\"1.0.0\"}}}' ;; *) exit 1 ;; esac; \
IFS= read -r lifecycle || exit 1; \
case \"$lifecycle\" in *notifications/initialized*) ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *tools/call*) ;; *) exit 1 ;; esac; \
case \"$request\" in *io.modelcontextprotocol/protocolVersion*) exit 1 ;; \
*) printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"legacy result\",\"annotations\":{\"audience\":[\"user\"]},\"_meta\":{\"io.fastmcp.legacy\":true},\"io.fastmcp/extension\":{\"kept\":true}}],\"isError\":false,\"_meta\":{\"io.fastmcp.result\":true},\"io.fastmcp.resultExtension\":{\"kept\":true}}}' ;; esac; \
exec sleep 2"
}
#[cfg(unix)]
fn legacy_typed_list_client_script() -> &'static str {
"IFS= read -r first || exit 1; \
case \"$first\" in *initialize*2024-11-05*) \
printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"serverInfo\":{\"name\":\"legacy-server\",\"version\":\"1.0.0\"}}}' ;; *) exit 1 ;; esac; \
IFS= read -r lifecycle || exit 1; \
case \"$lifecycle\" in *notifications/initialized*) ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *tools/list*) ;; *) exit 1 ;; esac; \
case \"$request\" in *io.modelcontextprotocol/protocolVersion*) exit 1 ;; \
*) printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"tools\":[]}}' ;; esac; \
exec sleep 2"
}
#[cfg(unix)]
fn legacy_progress_client_script(progress_token: i64) -> String {
format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *'\"method\":\"initialize\"'*) ;; *) exit 1 ;; esac; \
case \"$first\" in *'\"protocolVersion\":\"2024-11-05\"'*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{{}},\"serverInfo\":{{\"name\":\"legacy-server\",\"version\":\"1.0.0\"}}}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r lifecycle || exit 1; \
case \"$lifecycle\" in *notifications/initialized*) ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *tools/call*) ;; *) exit 1 ;; esac; \
case \"$request\" in *'\"progressToken\":2'*) ;; *) exit 1 ;; esac; \
case \"$request\" in *io.modelcontextprotocol/protocolVersion*) exit 1 ;; \
*) printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{{\"progressToken\":{progress_token},\"progress\":0.5,\"total\":1.0,\"message\":\"legacy progress\"}}}}'; \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"content\":[{{\"type\":\"text\",\"text\":\"legacy result\"}}],\"isError\":false}}}}' ;; esac; \
exec sleep 2"
)
}
#[cfg(unix)]
fn legacy_log_level_client_script() -> &'static str {
"IFS= read -r first || exit 1; \
case \"$first\" in *initialize*2024-11-05*) \
printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"serverInfo\":{\"name\":\"legacy-server\",\"version\":\"1.0.0\"}}}' ;; *) exit 1 ;; esac; \
IFS= read -r lifecycle || exit 1; \
case \"$lifecycle\" in *notifications/initialized*) ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *logging/setLevel*) ;; *) exit 1 ;; esac; \
case \"$request\" in *'\"level\":\"info\"'*) ;; *) exit 1 ;; esac; \
case \"$request\" in *io.modelcontextprotocol/logLevel*|*io.modelcontextprotocol/protocolVersion*) exit 1 ;; \
*) printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{}}' ;; esac; \
exec sleep 2"
}
#[cfg(unix)]
fn auto_legacy_log_level_client_script() -> &'static str {
"IFS= read -r first || exit 1; \
case \"$first\" in \
*server/discover*) \
printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32601,\"message\":\"Method not found\"}}' ;; \
*initialize*2024-11-05*) \
printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"serverInfo\":{\"name\":\"legacy-server\",\"version\":\"1.0.0\"}}}'; \
IFS= read -r lifecycle || exit 1; \
case \"$lifecycle\" in *notifications/initialized*) ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *logging/setLevel*'\"level\":\"info\"'*) ;; *) exit 1 ;; esac; \
case \"$request\" in *io.modelcontextprotocol/logLevel*|*io.modelcontextprotocol/protocolVersion*) exit 1 ;; \
*) printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{}}' ;; esac ;; \
*) exit 1 ;; esac; \
exec sleep 2"
}
#[cfg(unix)]
fn legacy_completion_client_script() -> &'static str {
"IFS= read -r first || exit 1; \
case \"$first\" in *initialize*2024-11-05*) \
printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"serverInfo\":{\"name\":\"legacy-server\",\"version\":\"1.0.0\"}}}' ;; *) exit 1 ;; esac; \
IFS= read -r lifecycle || exit 1; \
case \"$lifecycle\" in *notifications/initialized*) ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *completion/complete*) ;; *) exit 1 ;; esac; \
case \"$request\" in *io.modelcontextprotocol/protocolVersion*) exit 1 ;; \
*) printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"completion\":{\"values\":[\"staging\"],\"total\":1,\"hasMore\":false}}}' ;; esac; \
exec sleep 2"
}
#[cfg(unix)]
fn auto_legacy_completion_client_script() -> &'static str {
"IFS= read -r first || exit 1; \
case \"$first\" in \
*server/discover*) \
printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32601,\"message\":\"Method not found\"}}' ;; \
*initialize*2024-11-05*) \
printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"serverInfo\":{\"name\":\"legacy-server\",\"version\":\"1.0.0\"}}}'; \
IFS= read -r lifecycle || exit 1; \
case \"$lifecycle\" in *notifications/initialized*) ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *completion/complete*) ;; *) exit 1 ;; esac; \
case \"$request\" in *io.modelcontextprotocol/protocolVersion*) exit 1 ;; \
*) printf '%s\\n' '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"completion\":{\"values\":[\"staging\"],\"total\":1,\"hasMore\":false}}}' ;; esac ;; \
*) exit 1 ;; esac; \
exec sleep 2"
}
fn completion_params() -> CompletionParams {
CompletionParams {
reference: CompletionReference::Prompt {
name: "deploy".to_owned(),
},
argument: CompletionArgument {
name: "environment".to_owned(),
value: "sta".to_owned(),
},
context: None,
}
}
fn modern_completion_params() -> CompletionParams {
CompletionParams {
reference: CompletionReference::PromptWithTitle {
name: "deploy".to_owned(),
title: "Deploy".to_owned(),
},
argument: CompletionArgument {
name: "environment".to_owned(),
value: "sta".to_owned(),
},
context: Some(CompletionContext {
arguments: Some(std::collections::BTreeMap::from([(
"region".to_owned(),
"us-east-1".to_owned(),
)])),
}),
}
}
fn completion_params_with_context() -> CompletionParams {
let mut params = completion_params();
params.context = Some(CompletionContext {
arguments: Some(std::collections::BTreeMap::from([(
"region".to_owned(),
"us-east-1".to_owned(),
)])),
});
params
}
#[cfg(unix)]
#[test]
fn clt_01_i_positive() {
let modern_result = modern_discovery_response("modern-server", &[MODERN_PROTOCOL_VERSION]);
let script = modern_public_client_script(&modern_result);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern-only discovery initializes the public client");
assert_eq!(client.protocol_policy(), ProtocolPolicy::ModernOnly);
assert_eq!(
client.selected_protocol_era(),
Some(ProtocolEra::Modern2026)
);
assert_eq!(client.protocol_version(), MODERN_PROTOCOL_VERSION);
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_modern_ping_rejects_before_request_mutation() {
let modern_result =
modern_discovery_response("modern-ping-server", &[MODERN_PROTOCOL_VERSION]);
let script = modern_public_client_script(&modern_result);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the client");
let next_id_before = client.next_id.load(Ordering::SeqCst);
let error = client
.ping()
.expect_err("ping belongs exclusively to exact MCP 2024-11-05");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(client.next_id.load(Ordering::SeqCst), next_id_before);
assert!(client.is_initialized());
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_modern_server_ping_is_rejected_during_public_request() {
let script = modern_reverse_ping_client_script();
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the public client");
let content = client
.call_tool("echo", serde_json::json!({"text": "reverse ping"}))
.expect("the modern peer observes method-not-found before its complete response");
assert_eq!(content.len(), 1);
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_final_typed_client_result_positive() {
let script = modern_typed_call_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","content":[{"type":"text","text":"typed result","annotations":{"audience":["user"]},"_meta":{"io.fastmcp.retained":true},"io.fastmcp/extension":"retained"}],"isError":false,"structuredContent":{"answer":"typed result"}}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the public client");
let result = client
.call_tool_typed("echo", serde_json::json!({"text": "typed"}))
.expect("a negotiated modern tool call retains its typed final result");
let CoreResult::Final(FinalCoreResult::ToolsCall { result, diagnostic }) = result else {
panic!("modern tools/call must not decode through the legacy result shape");
};
assert!(diagnostic.is_none());
assert!(!result.payload.is_error);
assert_eq!(
result.payload.structured_content,
Some(serde_json::json!({"answer": "typed result"}))
);
let [
ContentBlock::Text {
text,
annotations,
meta,
additional,
},
] = result.payload.content.as_slice()
else {
panic!("typed tools/call must retain the complete final content block");
};
assert_eq!(text, "typed result");
assert!(annotations.is_some());
assert!(meta.is_some());
assert_eq!(
additional.get("io.fastmcp/extension"),
Some(&serde_json::json!("retained"))
);
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_stdio_result_source_preserves_unknown_order_and_number_lexemes() {
let script = modern_typed_call_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"content":[],"zeta":{"second":2,"first":1},"isError":false,"alpha":1.20e+4}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the exact-source client");
assert!(
client.selected_io.is_some(),
"a live selected stdio client installs its negotiated shared halves"
);
let result = client
.call_tool_typed("echo", serde_json::json!({}))
.expect("an absent discriminator uses the bounded modern compatibility rule");
let CoreResult::Final(FinalCoreResult::ToolsCall { result, diagnostic }) = result else {
panic!("the public stdio path must return the selected final tool result");
};
assert_eq!(
diagnostic,
Some(fastmcp_protocol::ResultPeerDiagnostic::ModernMissingResultType)
);
let extras = result.extras.members();
assert_eq!(
extras
.iter()
.map(|member| member.name.as_str())
.collect::<Vec<_>>(),
vec!["zeta", "alpha"],
"unknown top-level members retain admitted order",
);
let fastmcp_protocol::ExactJsonValue::Object(zeta) = &extras[0].value else {
panic!("zeta remains an exact object");
};
assert_eq!(
zeta.members()
.iter()
.map(|member| member.name.as_str())
.collect::<Vec<_>>(),
vec!["second", "first"],
"nested member order survives the shipped correlation boundary",
);
assert_eq!(
extras[1].value,
fastmcp_protocol::ExactJsonValue::Number("1.20e+4".to_owned()),
"the original number lexeme reaches exact result decoding",
);
client.close().expect("exact-source client cleanup");
}
#[cfg(unix)]
#[test]
#[cfg(feature = "tasks")]
fn clt_tasks_final_get_update_cancel_positive() {
let script = modern_final_tasks_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","taskId":"task-1","status":"input_required","createdAt":"2026-07-28T00:00:00Z","lastUpdatedAt":"2026-07-28T00:00:00Z","ttlMs":null,"inputRequests":{}}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("Tasks-capable modern discovery initializes the client");
let task_id = FinalTaskId::parse("task-1").expect("typed final task ID");
let task = client
.get_task_final(task_id.clone())
.expect("the admitted final tasks/get response retains its task");
let FinalTask::InputRequired {
base,
input_requests,
} = &task.task
else {
panic!("the exact task result must retain input_required state");
};
assert_eq!(base.task_id, task_id);
assert!(input_requests.is_empty());
let acknowledgement = client
.update_task_final(&task.task, BTreeMap::new())
.expect("an empty response map matches the exact empty input ledger");
assert!(acknowledgement.meta.is_none());
assert!(acknowledgement.additional.is_empty());
let cancellation = client
.cancel_task_final(task_id)
.expect("the admitted final tasks/cancel acknowledgement is exact and empty");
assert!(cancellation.meta.is_none());
assert!(cancellation.additional.is_empty());
client.close().expect("modern Tasks client cleanup");
}
#[cfg(unix)]
#[test]
#[cfg(feature = "tasks")]
fn clt_tasks_final_undeclared_capability_rejects_before_request_mutation() {
// This differs from the admitted Tasks discovery only by the absent
// `io.modelcontextprotocol/tasks` capability declaration.
let discovery =
modern_discovery_response("tasks-undeclared-server", &[MODERN_PROTOCOL_VERSION]);
let script = modern_public_client_script(&discovery);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes before the extension gate");
let next_id_before = client.next_id.load(Ordering::SeqCst);
let error = client
.get_task_final(FinalTaskId::parse("task-1").expect("typed final task ID"))
.expect_err("an undeclared extension cannot send tasks/get");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(client.next_id.load(Ordering::SeqCst), next_id_before);
client.close().expect("undeclared Tasks client cleanup");
}
#[cfg(unix)]
#[test]
#[cfg(feature = "tasks")]
fn clt_tasks_final_nonempty_settings_reject_before_request_mutation() {
// This differs from the admitted Tasks discovery only by one setting;
// the official extension admits exactly the empty object.
let discovery = modern_tasks_discovery_response(
"tasks-settings-server",
serde_json::json!({
"mode": "unsupported"
}),
);
let script = modern_public_client_script(&discovery);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery retains extension settings before method admission");
let next_id_before = client.next_id.load(Ordering::SeqCst);
let error = client
.get_task_final(FinalTaskId::parse("task-1").expect("typed final task ID"))
.expect_err("nonempty official Tasks settings cannot admit tasks/get");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(client.next_id.load(Ordering::SeqCst), next_id_before);
client.close().expect("rejected Tasks settings cleanup");
}
#[cfg(unix)]
#[test]
fn clt_ext_raw_stdio_public_request_uses_the_negotiated_registry() {
let (extension_id, registry, discovery) = raw_extension_configuration(
ExtensionDirection::ClientToServer,
fastmcp_protocol::extensions::ExtensionFallbackPolicy::RejectOneSided,
);
let script = modern_raw_extension_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"echoed":{"input":"ok"},"resultType":"complete"}}"#,
);
let mut client = ClientBuilder::new()
.extension_registry(registry, discovery, || accept_raw_extension_settings)
.expect("frozen raw extension registry is builder-owned")
.connect_stdio_with_cx("sh", &["-c", script.as_str()], &Cx::for_request())
.expect("raw extension discovery negotiates the public stdio client");
assert!(client.negotiated_extensions().is_some());
let result = client
.request_final_extension(
&extension_id,
"example/echo",
serde_json::json!({ "input": "ok" }),
)
.expect("the negotiated client-to-server raw extension request succeeds");
assert_eq!(result["echoed"]["input"], "ok");
client.close().expect("raw extension stdio client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_ext_raw_resolver_is_fresh_for_each_negotiation_retry() {
let (_extension_id, registry, client_discovery) = raw_extension_configuration(
ExtensionDirection::ClientToServer,
fastmcp_protocol::extensions::ExtensionFallbackPolicy::RejectOneSided,
);
let factory_calls = Arc::new(AtomicUsize::new(0));
let factory_calls_for_runtime = Arc::clone(&factory_calls);
let runtime = ClientExtensionRuntime::new(registry, client_discovery, move || {
factory_calls_for_runtime.fetch_add(1, Ordering::SeqCst);
OneShotRawExtensionResolver::default()
})
.expect("retry canary configuration freezes its immutable registry");
let wire: serde_json::Value =
serde_json::from_str(&modern_raw_extension_discovery_response(
"raw-extension-retry-server",
Some(serde_json::json!({ "mode": "raw" })),
))
.expect("retry discovery envelope serializes");
let discovery = serde_json::from_value(wire["result"].clone())
.expect("retry discovery result remains protocol-valid");
assert!(
runtime.negotiate(&discovery).is_ok(),
"the initial discovery receives one fresh resolver"
);
assert!(
runtime.negotiate(&discovery).is_ok(),
"a retry cannot inherit mutable resolver state from the failed attempt"
);
assert_eq!(
factory_calls.load(Ordering::SeqCst),
2,
"each negotiation attempt invokes the factory for a distinct resolver instance"
);
}
#[cfg(unix)]
#[test]
fn clt_ext_raw_shared_arc_factory_is_not_treated_as_resolver_isolation() {
let (_extension_id, registry, client_discovery) = raw_extension_configuration(
ExtensionDirection::ClientToServer,
fastmcp_protocol::extensions::ExtensionFallbackPolicy::RejectOneSided,
);
let shared = Arc::new(Mutex::new(OneShotRawExtensionResolver::default()));
let factory_calls = Arc::new(AtomicUsize::new(0));
let shared_for_factory = Arc::clone(&shared);
let factory_calls_for_runtime = Arc::clone(&factory_calls);
let runtime = ClientExtensionRuntime::new(registry, client_discovery, move || {
factory_calls_for_runtime.fetch_add(1, Ordering::SeqCst);
SharedArcRawExtensionResolver {
shared: Arc::clone(&shared_for_factory),
}
})
.expect("the factory boundary accepts a resolver only when it constructs one");
let wire: serde_json::Value =
serde_json::from_str(&modern_raw_extension_discovery_response(
"raw-extension-shared-resolver-server",
Some(serde_json::json!({ "mode": "raw" })),
))
.expect("shared resolver discovery envelope serializes");
let discovery = serde_json::from_value(wire["result"].clone())
.expect("shared resolver discovery result remains protocol-valid");
assert!(
runtime.negotiate(&discovery).is_ok(),
"the first factory-produced wrapper observes the shared resolver once"
);
let error = runtime
.negotiate(&discovery)
.expect_err("a factory returning shared Arc state cannot masquerade as isolated");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(
factory_calls.load(Ordering::SeqCst),
2,
"the runtime did invoke the factory twice; the failure comes only from its shared output"
);
}
#[cfg(unix)]
#[test]
fn clt_ext_raw_cloned_builders_isolate_extension_resolvers() {
let (_extension_id, registry, discovery) = raw_extension_configuration(
ExtensionDirection::ClientToServer,
fastmcp_protocol::extensions::ExtensionFallbackPolicy::RejectOneSided,
);
let builder = ClientBuilder::new()
.extension_registry(registry, discovery, OneShotRawExtensionResolver::default)
.expect("one frozen registry is cloneable as an immutable builder plan");
let first_script = modern_raw_extension_no_contact_client_script(Some(serde_json::json!({
"mode": "raw"
})));
let mut first = builder
.clone()
.connect_stdio_with_cx("sh", &["-c", first_script.as_str()], &Cx::for_request())
.expect("the first cloned builder gets an isolated resolver");
first
.close()
.expect("the first isolated raw extension client closes");
let second_script =
modern_raw_extension_no_contact_client_script(Some(serde_json::json!({
"mode": "raw"
})));
let mut second = builder
.connect_stdio_with_cx("sh", &["-c", second_script.as_str()], &Cx::for_request())
.expect("a sibling cloned builder cannot inherit the first resolver state");
second
.close()
.expect("the second isolated raw extension client closes");
}
#[cfg(unix)]
#[test]
fn clt_ext_raw_stdio_start_and_executor_reject_registered_methods_without_contact() {
let (_extension_id, registry, discovery) = raw_extension_configuration(
ExtensionDirection::ClientToServer,
fastmcp_protocol::extensions::ExtensionFallbackPolicy::RejectOneSided,
);
let script = modern_raw_extension_no_contact_client_script(Some(serde_json::json!({
"mode": "raw"
})));
let mut client = ClientBuilder::new()
.extension_registry(registry, discovery, || accept_raw_extension_settings)
.expect("raw extension registry is frozen before raw API admission")
.connect_stdio_with_cx("sh", &["-c", script.as_str()], &Cx::for_request())
.expect("modern discovery negotiates the registered raw method");
let next_id_before = client.next_id.load(Ordering::SeqCst);
let error = client
.start_multiplexed_request(
&Cx::for_request(),
"example/echo",
Some(serde_json::json!({})),
)
.expect_err("the public raw start surface cannot bypass extension admission");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(client.next_id.load(Ordering::SeqCst), next_id_before);
let executor = client
.multiplexed_stdio_executor()
.expect("initialized stdio client exposes the public executor");
let error = executor
.execute(
&Cx::for_request(),
"example/echo",
Some(serde_json::json!({})),
)
.expect_err("the public raw executor cannot bypass extension admission");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(client.next_id.load(Ordering::SeqCst), next_id_before);
client
.close()
.expect("raw bypass rejections leave no stdio request to clean up");
}
#[cfg(unix)]
#[test]
fn clt_ext_raw_stdio_unnegotiated_settings_do_not_contact_the_peer() {
// This differs from the admitted descriptor only in its one-sided
// fallback: an absent server setting now selects an inactive set.
let (extension_id, registry, discovery) = raw_extension_configuration(
ExtensionDirection::ClientToServer,
fastmcp_protocol::extensions::ExtensionFallbackPolicy::ClientInactiveFallback,
);
let script = modern_raw_extension_no_contact_client_script(None);
let mut client = ClientBuilder::new()
.extension_registry(registry, discovery, || accept_raw_extension_settings)
.expect("raw extension registry permits the inactive fallback")
.connect_stdio_with_cx("sh", &["-c", script.as_str()], &Cx::for_request())
.expect("modern discovery retains inactive raw extension state");
let next_id_before = client.next_id.load(Ordering::SeqCst);
let error = client
.request_final_extension(&extension_id, "example/echo", serde_json::json!({}))
.expect_err("unnegotiated settings cannot allocate or write a raw extension request");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(client.next_id.load(Ordering::SeqCst), next_id_before);
client
.close()
.expect("inactive raw extension client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_ext_raw_stdio_wrong_direction_does_not_contact_the_peer() {
// This differs from the admitted descriptor only in method direction.
let (extension_id, registry, discovery) = raw_extension_configuration(
ExtensionDirection::ServerToClient,
fastmcp_protocol::extensions::ExtensionFallbackPolicy::RejectOneSided,
);
let script = modern_raw_extension_no_contact_client_script(Some(serde_json::json!({
"mode": "raw"
})));
let mut client = ClientBuilder::new()
.extension_registry(registry, discovery, || accept_raw_extension_settings)
.expect("server-to-client raw descriptor freezes for discovery")
.connect_stdio_with_cx("sh", &["-c", script.as_str()], &Cx::for_request())
.expect("raw extension negotiation itself remains bilateral");
let next_id_before = client.next_id.load(Ordering::SeqCst);
let error = client
.request_final_extension(&extension_id, "example/echo", serde_json::json!({}))
.expect_err("a server-owned raw method cannot contact the peer through the client API");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(client.next_id.load(Ordering::SeqCst), next_id_before);
client
.close()
.expect("wrong-direction raw extension client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_ext_raw_stdio_exact_2024_rejects_without_contact() {
let (extension_id, registry, discovery) = raw_extension_configuration(
ExtensionDirection::ClientToServer,
fastmcp_protocol::extensions::ExtensionFallbackPolicy::RejectOneSided,
);
let mut client = ClientBuilder::new()
.extension_registry(registry, discovery, || accept_raw_extension_settings)
.expect("raw extension registry may be configured before era selection")
.protocol_plan(ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly))
.connect_stdio_with_cx(
"sh",
&["-c", legacy_raw_extension_no_contact_client_script()],
&Cx::for_request(),
)
.expect("exact legacy lifecycle completes before raw extension rejection");
let next_id_before = client.next_id.load(Ordering::SeqCst);
let error = client
.request_final_extension(&extension_id, "example/echo", serde_json::json!({}))
.expect_err("exact 2024-11-05 excludes every final generic extension");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(client.next_id.load(Ordering::SeqCst), next_id_before);
let error = client
.start_multiplexed_request(
&Cx::for_request(),
"example/echo",
Some(serde_json::json!({})),
)
.expect_err("exact 2024 raw start cannot bypass final extension exclusion");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(client.next_id.load(Ordering::SeqCst), next_id_before);
let executor = client
.multiplexed_stdio_executor()
.expect("exact legacy initialization still exposes raw stdio executor");
let error = executor
.execute(
&Cx::for_request(),
"example/echo",
Some(serde_json::json!({})),
)
.expect_err("exact 2024 raw executor cannot bypass final extension exclusion");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(client.next_id.load(Ordering::SeqCst), next_id_before);
client.close().expect("legacy raw extension client cleanup");
}
#[cfg(unix)]
#[test]
#[cfg(feature = "tasks")]
fn clt_tasks_final_wrong_task_id_terminates_the_connection() {
// This differs from the admitted get response only in the returned
// taskId. A response for another opaque ID must not be accepted.
let script = modern_final_tasks_get_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","taskId":"task-2","status":"input_required","createdAt":"2026-07-28T00:00:00Z","lastUpdatedAt":"2026-07-28T00:00:00Z","ttlMs":null,"inputRequests":{}}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("Tasks-capable modern discovery initializes the client");
let error = client
.get_task_final(FinalTaskId::parse("task-1").expect("typed final task ID"))
.expect_err("a mismatched final task ID is a peer contradiction");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(!client.is_initialized());
client.close().expect("contradictory Tasks client cleanup");
}
#[cfg(unix)]
#[test]
#[cfg(feature = "tasks")]
fn forced_stdio_valid_task_terminal_cancellation_election_retains_committed_handle() {
assert_forced_stdio_task_terminal_cancellation_election(
r#"{"jsonrpc":"2.0","id":91,"result":{"resultType":"task","taskId":"task-91","status":"working","createdAt":"2026-07-28T12:00:00.000Z","lastUpdatedAt":"2026-07-28T12:00:00.000Z","ttlMs":null}}"#,
true,
);
}
#[cfg(unix)]
#[test]
#[cfg(feature = "tasks")]
fn forced_stdio_complete_terminal_remains_cancellation_first() {
// This differs from the admitted valid task terminal only in
// `resultType`.
assert_forced_stdio_task_terminal_cancellation_election(
r#"{"jsonrpc":"2.0","id":91,"result":{"resultType":"complete","taskId":"task-91","status":"working","createdAt":"2026-07-28T12:00:00.000Z","lastUpdatedAt":"2026-07-28T12:00:00.000Z","ttlMs":null}}"#,
false,
);
}
#[cfg(unix)]
#[test]
#[cfg(feature = "tasks")]
fn forced_stdio_malformed_task_terminal_remains_cancellation_first() {
// `CreateTaskResult` alone admits this additional member, but the
// shipped final tools/call decoder rejects top-level serverInfo.
let source = r#"{"jsonrpc":"2.0","id":91,"result":{"resultType":"task","taskId":"task-91","status":"working","createdAt":"2026-07-28T12:00:00.000Z","lastUpdatedAt":"2026-07-28T12:00:00.000Z","ttlMs":null,"serverInfo":{"name":"injected","version":"1.0.0"}}}"#;
let task = serde_json::from_str::<serde_json::Value>(source)
.expect("the malformed terminal remains JSON-RPC-shaped")["result"]
.clone();
assert!(
serde_json::from_value::<fastmcp_protocol::CreateTaskResult>(task).is_ok(),
"the old shallow task-only election would have accepted this terminal"
);
assert_forced_stdio_task_terminal_cancellation_election(source, false);
}
#[cfg(unix)]
#[test]
#[cfg(feature = "tasks")]
fn forced_stdio_error_terminal_remains_cancellation_first() {
assert_forced_stdio_task_terminal_cancellation_election(
r#"{"jsonrpc":"2.0","id":91,"error":{"code":-32000,"message":"task creation failed"}}"#,
false,
);
}
#[cfg(all(unix, feature = "tasks"))]
fn assert_forced_stdio_task_terminal_cancellation_election(source: &str, response_wins: bool) {
let script = modern_final_tool_task_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"task","taskId":"task-73","status":"working","createdAt":"2026-07-28T12:00:00.000Z","lastUpdatedAt":"2026-07-28T12:00:00.000Z","ttlMs":null}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern Tasks discovery initializes the forced-race client");
let parameters = client
.with_final_tasks_client_capability(serde_json::json!({
"name": "durable-tool",
"arguments": {},
}))
.expect("the forced race builds the shipped final tools/call request");
let request = CoreRequest::decode(ProtocolEra::Modern2026, "tools/call", Some(¶meters))
.expect("the forced race admits the shipped final tools/call request");
let request_id = RequestId::Number(91);
let waiter = client
.responses
.register(request_id.clone())
.expect("the forced race owns one response waiter");
let frame = ReceivedTransportFrame::admit(source.as_bytes().to_vec())
.expect("the terminal response is admitted before cancellation");
assert_eq!(
client
.route_received_response(frame)
.expect("the stdio reader routes the committed terminal response"),
ResponseRoute::Delivered
);
let cancellation = McpRequestCancellation::new();
assert!(
cancellation.cancel(),
"the forced race observes cancellation after response routing"
);
let deadlines = RequestDeadlines::start_at(client.timeout_policy, Instant::now())
.expect("the already-routed response does not depend on a deadline");
let received = client.recv_response_with_request_cancellation(
&Cx::for_request(),
waiter,
deadlines,
&cancellation,
RequestCancellationTerminalElection::FinalToolsCallTask {
request: Box::new(request),
},
);
if response_wins {
let response = received.expect(
"a committed shipped ToolsCallTask terminal wins the forced cancellation race",
);
assert_eq!(response.id, Some(request_id));
} else {
let error = received.expect_err("a non-Task terminal remains cancellation-first");
assert_eq!(error.code, McpErrorCode::RequestCancelled);
}
client.close().expect("forced-race client cleanup");
}
#[cfg(unix)]
#[test]
#[cfg(feature = "tasks")]
fn clt_tasks_final_tool_outcome_retains_exact_created_task() {
let script = modern_final_tool_task_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"task","taskId":"task-73","status":"working","createdAt":"2026-07-28T12:00:00.000Z","lastUpdatedAt":"2026-07-28T12:00:00.000Z","ttlMs":null}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("Tasks-capable discovery initializes the tool client");
let outcome = client
.call_tool_final_outcome("durable-tool", serde_json::json!({"work": 73}))
.expect("bilaterally negotiated tool result retains its exact task branch");
let FinalToolCallOutcome::Task(result) = outcome else {
panic!("Tasks-backed tools/call must not be projected into complete content");
};
assert_eq!(result.task.base().task_id.as_str(), "task-73");
assert!(matches!(result.task, FinalTask::Working(_)));
client.close().expect("final tool task client cleanup");
}
#[cfg(unix)]
#[test]
#[cfg(feature = "tasks")]
fn clt_tasks_final_tool_outcome_rejects_one_field_result_type_change() {
// This differs from the admitted task result only in `resultType`.
let script = modern_final_tool_task_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","taskId":"task-73","status":"working","createdAt":"2026-07-28T12:00:00.000Z","lastUpdatedAt":"2026-07-28T12:00:00.000Z","ttlMs":null}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("Tasks-capable discovery initializes before the planted response");
let error = client
.call_tool_final_outcome("durable-tool", serde_json::json!({"work": 73}))
.expect_err("one changed result discriminator must fail the connection closed");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(!client.is_initialized());
}
#[cfg(unix)]
#[test]
fn clt_01_mrtr_retry_replays_tools_resources_and_prompts_once() {
let responses =
|| BTreeMap::from([("roots".to_owned(), serde_json::json!({ "roots": [] }))]);
let script = modern_mrtr_retry_client_script(
"tools/call",
r#"{"resultType":"complete","content":[],"isError":false}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the MRTR tool client");
assert!(matches!(
client
.call_tool_with_mrtr_retry("retry-tool", serde_json::json!({}), |_| {
Ok(responses())
})
.expect("one final input-required tool result is retried once"),
CoreResult::Final(FinalCoreResult::ToolsCall { .. })
));
client.close().expect("MRTR tool client cleanup");
let script = modern_mrtr_retry_client_script(
"resources/read",
r#"{"resultType":"complete","contents":[],"ttlMs":0,"cacheScope":"private"}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the MRTR resource client");
assert!(matches!(
client
.read_resource_with_mrtr_retry("file:///retry.txt", |_| Ok(responses()))
.expect("one final input-required resource result is retried once"),
CoreResult::Final(FinalCoreResult::ResourcesRead { .. })
));
client.close().expect("MRTR resource client cleanup");
let script = modern_mrtr_retry_client_script(
"prompts/get",
r#"{"resultType":"complete","messages":[]}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the MRTR prompt client");
assert!(matches!(
client
.get_prompt_with_mrtr_retry("retry-prompt", HashMap::new(), |_| Ok(responses()))
.expect("one final input-required prompt result is retried once"),
CoreResult::Final(FinalCoreResult::PromptsGet { .. })
));
client.close().expect("MRTR prompt client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_mrtr_retry_continues_only_after_every_requested_input_key() {
let script = modern_mrtr_two_input_client_script(true);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the two-input MRTR client");
let result = client
.call_tool_with_mrtr_retry("retry-tool", serde_json::json!({}), |_| {
Ok(BTreeMap::from([
("roots".to_owned(), serde_json::json!({ "roots": [] })),
("sampling".to_owned(), serde_json::json!({ "messages": [] })),
]))
})
.expect("a retry with every requested input key reaches the terminal result");
assert!(matches!(
result,
CoreResult::Final(FinalCoreResult::ToolsCall { .. })
));
client.close().expect("two-input MRTR client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_mrtr_partial_input_map_rejects_without_a_retry_or_state_change() {
let script = modern_mrtr_two_input_client_script(false);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes before the partial MRTR map");
let mut callback_count = 0_usize;
let error = client
.call_tool_with_mrtr_retry("retry-tool", serde_json::json!({}), |_| {
callback_count += 1;
Ok(BTreeMap::from([(
"roots".to_owned(),
serde_json::json!({ "roots": [] }),
)]))
})
.expect_err("omitting only one requested key must reject before the retry write");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(
error.message,
"MRTR inputResponses must include every key requested by the peer"
);
assert_eq!(callback_count, 1);
assert!(
client.is_initialized(),
"a local partial-map rejection must leave the connection state unchanged"
);
client
.close()
.expect("partial-map rejection performs no retry contact");
}
#[cfg(unix)]
#[test]
fn clt_01_mrtr_multi_round_rebuilds_original_params_and_allows_state_only() {
let script = modern_mrtr_multi_round_client_script();
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the multi-round MRTR client");
let mut callback_count = 0;
let result = client
.call_tool_with_mrtr_retry(
"retry-tool",
serde_json::json!({ "round": 1 }),
|input_required| {
callback_count += 1;
match callback_count {
1 => {
assert_eq!(input_required.request_state(), Some("retry-1"));
Ok(BTreeMap::from([(
"roots".to_owned(),
serde_json::json!({ "roots": [] }),
)]))
}
2 => {
assert_eq!(input_required.request_state(), Some("retry-2"));
assert!(
input_required.input_requests().is_none(),
"the second continuation is intentionally state-only"
);
Ok(BTreeMap::new())
}
_ => panic!("the completed operation must not invoke another continuation"),
}
},
)
.expect("two input-required results continue to a public final result");
assert!(matches!(
result,
CoreResult::Final(FinalCoreResult::ToolsCall { .. })
));
assert_eq!(callback_count, 2);
client.close().expect("multi-round MRTR client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_mrtr_round_bound_rejects_one_extra_continuation_before_callback_or_send() {
let script = modern_mrtr_round_bound_client_script();
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the bound MRTR client");
let mut callback_count = 0;
let error = client
.call_tool_with_mrtr_retry("retry-tool", serde_json::json!({ "round": 1 }), |_| {
callback_count += 1;
Ok(BTreeMap::from([(
"roots".to_owned(),
serde_json::json!({ "roots": [] }),
)]))
})
.expect_err("one continuation beyond the round bound must fail locally");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(error.message, "MRTR continuation-round limit exceeded");
assert_eq!(callback_count, MAX_MRTR_CONTINUATION_ROUNDS);
client.close().expect("round-bound MRTR client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_01_mrtr_retry_keeps_exact_legacy_tool_behavior() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", legacy_typed_call_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("legacy initialization succeeds before the MRTR entry point");
let mut callback_count = 0;
let result = client
.call_tool_with_mrtr_retry("echo", serde_json::json!({ "text": "legacy" }), |_| {
callback_count += 1;
Ok(BTreeMap::new())
})
.expect("legacy MRTR entry retains the one-request typed behavior");
assert!(matches!(
result,
CoreResult::Legacy(LegacyCoreResult::ToolsCall(_))
));
assert_eq!(callback_count, 0);
client.close().expect("legacy MRTR client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_mrtr_retry_rejects_one_unrequested_response_key_before_retry() {
let script = modern_mrtr_retry_client_script(
"tools/call",
r#"{"resultType":"complete","content":[],"isError":false}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes before the planted MRTR response key");
let error = client
.call_tool_with_mrtr_retry("retry-tool", serde_json::json!({}), |_| {
Ok(BTreeMap::from([(
"other".to_owned(),
serde_json::json!({ "roots": [] }),
)]))
})
.expect_err("changing only the response key rejects the retry before a second request");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(
error.message,
"MRTR inputResponses contain a key not requested by the peer"
);
client.close().expect("planted MRTR response-key cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_exact_final_conveniences_preserve_final_open_fields() {
let script = modern_final_convenience_client_script(
"tools/call",
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","content":[{"type":"text","text":"exact tool result","_meta":{"io.fastmcp.retained":true},"io.fastmcp/extension":"retained"}],"isError":false,"structuredContent":{"answer":"exact tool result"}}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the final tool convenience client");
let tool_result: FinalCallToolResult = client
.call_tool_final("echo", serde_json::json!({"text": "exact"}))
.expect("the exact final tool convenience retains structured output");
assert_eq!(
tool_result.structured_content,
Some(serde_json::json!({"answer": "exact tool result"}))
);
let [
ContentBlock::Text {
text,
meta,
additional,
..
},
] = tool_result.content.as_slice()
else {
panic!("the exact final tool convenience retains final text content");
};
assert_eq!(text, "exact tool result");
assert!(meta.is_some());
assert_eq!(
additional.get("io.fastmcp/extension"),
Some(&serde_json::json!("retained"))
);
client.close().expect("modern tool client cleanup");
let script = modern_final_convenience_client_script(
"resources/read",
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","contents":[{"uri":"file:///exact.txt","text":"exact resource","mimeType":"text/plain","_meta":{"io.fastmcp.retained":true},"io.fastmcp/extension":"retained"}],"ttlMs":7.3e1,"cacheScope":"public"}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the final resource convenience client");
let resource_result: FinalReadResourceResult = client
.read_resource_final("file:///exact.txt")
.expect("the exact final resource convenience retains cache directives");
assert_eq!(resource_result.ttl_ms.as_str(), "7.3e1");
assert_eq!(
resource_result
.ttl_ms
.try_as_millis()
.expect("resource TTL fits the local duration domain"),
73
);
assert_eq!(
resource_result.cache_scope,
fastmcp_protocol::CacheScope::Public
);
let [
EmbeddedResourceContents::Text {
uri,
text,
mime_type,
meta,
additional,
},
] = resource_result.contents.as_slice()
else {
panic!("the exact final resource convenience retains final resource content");
};
assert_eq!(uri.as_str(), "file:///exact.txt");
assert_eq!(text, "exact resource");
assert_eq!(mime_type.as_deref(), Some("text/plain"));
assert!(meta.is_some());
assert_eq!(
additional.get("io.fastmcp/extension"),
Some(&serde_json::json!("retained"))
);
client.close().expect("modern resource client cleanup");
let script = modern_final_convenience_client_script(
"prompts/get",
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","description":"exact prompt","messages":[{"role":"user","content":{"type":"text","text":"exact prompt content","_meta":{"io.fastmcp.retained":true},"io.fastmcp/extension":"retained"}}]}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the final prompt convenience client");
let prompt_result: FinalGetPromptResult = client
.get_prompt_final("summary", HashMap::new())
.expect("the exact final prompt convenience retains the final description");
assert_eq!(prompt_result.description.as_deref(), Some("exact prompt"));
let [
fastmcp_protocol::FinalPromptMessage {
role: fastmcp_protocol::Role::User,
content:
ContentBlock::Text {
text,
meta,
additional,
..
},
},
] = prompt_result.messages.as_slice()
else {
panic!("the exact final prompt convenience retains final prompt content");
};
assert_eq!(text, "exact prompt content");
assert!(meta.is_some());
assert_eq!(
additional.get("io.fastmcp/extension"),
Some(&serde_json::json!("retained"))
);
client.close().expect("modern prompt client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_01_exact_final_conveniences_reject_one_field_cross_era_before_request_mutation() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", legacy_public_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("legacy-only initializes the exact client");
let next_id_before = client.next_id.load(Ordering::SeqCst);
let tool_error = client
.call_tool_final("echo", serde_json::json!({"text": "legacy"}))
.expect_err("the exact final tool convenience must reject a legacy session");
assert_eq!(tool_error.code, McpErrorCode::InvalidParams);
let resource_error = client
.read_resource_final("file:///legacy.txt")
.expect_err("the exact final resource convenience must reject a legacy session");
assert_eq!(resource_error.code, McpErrorCode::InvalidParams);
let prompt_error = client
.get_prompt_final("summary", HashMap::new())
.expect_err("the exact final prompt convenience must reject a legacy session");
assert_eq!(prompt_error.code, McpErrorCode::InvalidParams);
assert_eq!(
client.next_id.load(Ordering::SeqCst),
next_id_before,
"changing only the selected era must reject every final convenience before ID allocation"
);
client
.ping()
.expect("the rejected final conveniences leave the legacy request stream untouched");
client.close().expect("legacy client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_modern_convenience_tool_projects_final_content() {
let script = modern_typed_call_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","content":[{"type":"text","text":"convenience result"}],"isError":false}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the public client");
let content = client
.call_tool("echo", serde_json::json!({"text": "convenience"}))
.expect("the convenience API projects final content instead of decoding it as legacy");
assert!(matches!(
content.as_slice(),
[LegacyContent::Text { text, .. }] if text == "convenience result"
));
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_modern_convenience_tool_rejects_structured_content_loss() {
// This differs from the representable convenience result only in
// structuredContent.
let script = modern_typed_call_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","content":[{"type":"text","text":"convenience result"}],"isError":false,"structuredContent":{"answer":"convenience result"}}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("same modern discovery initializes the public client");
let error = client
.call_tool("echo", serde_json::json!({"text": "convenience"}))
.expect_err("the legacy convenience API must not discard structuredContent");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(client.is_initialized());
assert!(client.responses.terminal_error().is_none());
client
.close()
.expect("local projection rejection leaves the client usable");
}
#[cfg(unix)]
#[test]
fn clt_01_modern_convenience_tool_rejects_resource_link_loss() {
let script = modern_typed_call_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","content":[{"type":"resource_link","name":"manual","uri":"https://example.com/manual"}],"isError":false}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the public client");
let error = client
.call_tool("echo", serde_json::json!({"text": "convenience"}))
.expect_err("the legacy convenience API cannot represent resource_link content");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(client.is_initialized());
client
.close()
.expect("local projection rejection leaves the client usable");
}
#[cfg(unix)]
#[test]
fn clt_01_modern_convenience_tool_null_discriminator_rejected() {
// This differs from the accepted convenience result only in `resultType`.
let script = modern_typed_call_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":null,"content":[{"type":"text","text":"convenience result"}],"isError":false}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("same modern discovery initializes the public client");
let error = client
.call_tool("echo", serde_json::json!({"text": "convenience"}))
.expect_err("an explicit null discriminator remains a terminal protocol violation");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(!client.is_initialized());
assert!(client.responses.terminal_error().is_some());
}
#[cfg(unix)]
#[test]
fn clt_01_remaining_typed_core_methods_return_final_results() {
let script = modern_remaining_core_client_script();
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the public client");
assert!(matches!(
client
.list_tools_typed(None)
.expect("typed tools/list returns final result"),
CoreResult::Final(FinalCoreResult::ToolsList { .. })
));
assert!(matches!(
client
.list_resources_typed(None)
.expect("typed resources/list returns final result"),
CoreResult::Final(FinalCoreResult::ResourcesList { .. })
));
assert!(matches!(
client
.list_resource_templates_typed(None)
.expect("typed resources/templates/list returns final result"),
CoreResult::Final(FinalCoreResult::ResourceTemplatesList { .. })
));
assert!(matches!(
client
.read_resource_typed("file:///typed-core-resource")
.expect("typed resources/read returns final result"),
CoreResult::Final(FinalCoreResult::ResourcesRead { .. })
));
assert!(matches!(
client
.list_prompts_typed(None)
.expect("typed prompts/list returns final result"),
CoreResult::Final(FinalCoreResult::PromptsList { .. })
));
assert!(matches!(
client
.get_prompt_typed("summary", HashMap::new())
.expect("typed prompts/get returns final result"),
CoreResult::Final(FinalCoreResult::PromptsGet { .. })
));
client
.set_log_level_typed(LoggingLevel::Notice)
.expect("modern logging configuration is retained for later request metadata");
assert!(matches!(
client
.list_tools_typed(None)
.expect("configured modern metadata is carried by a supported core request"),
CoreResult::Final(FinalCoreResult::ToolsList { .. })
));
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_modern_log_level_metadata_is_absent_until_configured() {
let script = modern_log_level_absence_client_script();
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("same modern discovery initializes the public client");
assert!(matches!(
client
.list_tools_typed(None)
.expect("one omitted final logging configuration remains absent on the wire"),
CoreResult::Final(FinalCoreResult::ToolsList { .. })
));
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_01_auto_modern_log_level_uses_later_request_metadata() {
let script = modern_log_level_metadata_client_script();
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::Auto),
Cx::for_request(),
)
.expect("recognized final discovery selects modern under Auto");
client
.set_log_level_typed(LoggingLevel::Notice)
.expect("Auto-modern stores final configuration without a logging RPC");
assert!(matches!(
client
.list_tools_typed(None)
.expect("the following Auto-modern request carries the final log level"),
CoreResult::Final(FinalCoreResult::ToolsList { .. })
));
client.close().expect("Auto-modern client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn leg_03_log_level_preserves_exact_legacy_rpc() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", legacy_log_level_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("legacy-only initializes the exact client");
client
.set_log_level(LogLevel::Info)
.expect("legacy logging retains its exact RPC acknowledgement");
client.close().expect("legacy client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_01_auto_legacy_log_level_preserves_exact_rpc() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", auto_legacy_log_level_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::Auto),
Cx::for_request(),
)
.expect("recognized final refusal selects exact legacy under Auto");
client
.set_log_level(LogLevel::Info)
.expect("Auto-legacy keeps the historical logging RPC");
client.close().expect("Auto-legacy client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_subscriptions_listen_collects_typed_request_owned_notifications() {
let script = modern_subscriptions_listen_client_script(
2,
&[
r#"{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}"#,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/subscriptionId":2}}}"#,
],
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the subscription client");
let collector = client
.listen_subscriptions_typed(SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
})
.expect("modern subscription listener collects its owned notification stream");
assert_eq!(collector.subscription_id, RequestId::from(2));
assert_eq!(collector.accepted_filter.tools_list_changed, Some(true));
assert!(matches!(
collector.notifications.as_slice(),
[ServerNotification::ToolsListChanged(None)]
));
assert!(matches!(
collector.terminal.payload,
FinalSubscriptionsListenResult {}
));
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[test]
fn live_catalog_listener_routes_acknowledgement_list_changed_and_terminal() {
let script = modern_subscriptions_listen_client_script(
2,
&[
r#"{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}"#,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/subscriptionId":2}}}"#,
],
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the incremental catalog listener");
client
.open_subscriptions_listener(SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
})
.expect("the public incremental listener commits one selected-stdio subscription");
let cx = Cx::for_request();
let cancellation = McpRequestCancellation::new();
assert!(matches!(
client
.next_subscription_event(&cx, &cancellation)
.expect("public selected-stdio ingress routes the acknowledgement"),
StdioSubscriptionEvent::Acknowledged(ref filter)
if filter.tools_list_changed == Some(true)
));
assert!(matches!(
client
.next_subscription_event(&cx, &cancellation)
.expect("public selected-stdio ingress routes the catalog event"),
StdioSubscriptionEvent::Notification(ServerNotification::ToolsListChanged(None))
));
assert!(matches!(
client
.next_subscription_event(&cx, &cancellation)
.expect("public selected-stdio ingress routes the terminal response"),
StdioSubscriptionEvent::Terminal
));
assert!(
client.live_catalog_subscription.is_none(),
"terminal completion releases only the completed live listener"
);
client
.close()
.expect("incremental catalog listener cleanup");
}
#[cfg(unix)]
#[test]
fn live_catalog_listener_keeps_issuing_tools_call_on_the_same_client() {
let script = modern_incremental_catalog_listener_with_tool_call_script();
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the interleaved catalog listener");
client
.open_subscriptions_listener(SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
})
.expect("listen stays live while this client issues another request");
let cx = Cx::for_request();
let cancellation = McpRequestCancellation::new();
assert!(matches!(
client
.next_subscription_event(&cx, &cancellation)
.expect("acknowledgement arrives before the interleaved tools/call"),
StdioSubscriptionEvent::Acknowledged(_)
));
let result = client
.call_tool_typed("hide_greet", serde_json::json!({}))
.expect("the same client must complete tools/call while listen is live");
assert!(matches!(
result,
CoreResult::Final(FinalCoreResult::ToolsCall { .. })
));
assert!(matches!(
client
.next_subscription_event(&cx, &cancellation)
.expect("catalog events queued during tools/call stay request-owned"),
StdioSubscriptionEvent::Notification(ServerNotification::ToolsListChanged(None))
));
assert!(matches!(
client
.next_subscription_event(&cx, &cancellation)
.expect("the listen terminal remains available after tools/call"),
StdioSubscriptionEvent::Terminal
));
client
.close()
.expect("interleaved catalog listener cleanup");
}
#[cfg(unix)]
#[test]
fn live_catalog_listener_rejects_event_before_acknowledgement() {
let discovery_response = modern_discovery_response(
"catalog-listener-before-ack-server",
&[MODERN_PROTOCOL_VERSION],
);
let script = format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery_response}' ;; *) exit 1 ;; esac; \
IFS= read -r request || exit 1; \
case \"$request\" in *subscriptions/listen*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}}' \
;; *) exit 1 ;; esac; \
exec sleep 2"
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the before-ack catalog listener");
client
.open_subscriptions_listener(SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
})
.expect("the live listener commits before the unacknowledged event arrives");
let cx = Cx::for_request();
let cancellation = McpRequestCancellation::new();
let error = client
.next_subscription_event(&cx, &cancellation)
.expect_err("a catalog event before acknowledgement must fail closed");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert_eq!(
error.message,
"Subscription listener received an event before acknowledgement"
);
}
#[cfg(unix)]
#[test]
fn live_catalog_listener_rejects_an_empty_filter() {
let script = modern_subscriptions_listen_client_script(2, &[]);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the empty-filter catalog listener");
let error = client
.open_subscriptions_listener(SubscriptionFilter::default())
.expect_err("an empty filter must not commit a catalog listener");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert!(
error
.message
.contains("requires tools, resources, or prompts")
);
client
.close()
.expect("empty-filter catalog listener cleanup");
}
#[cfg(unix)]
#[test]
fn live_catalog_listener_rejects_a_second_open_on_the_same_client() {
let script = modern_subscriptions_listen_client_script(
2,
&[
r#"{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}"#,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/subscriptionId":2}}}"#,
],
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the exclusive catalog listener");
let filter = SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
};
client
.open_subscriptions_listener(filter.clone())
.expect("the first live catalog listener commits");
let error = client
.open_subscriptions_listener(filter)
.expect_err("a second live catalog listener must not replace the first");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert_eq!(
error.message,
"A final catalog stdio subscription is already active on this client"
);
client.close().expect("exclusive catalog listener cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn live_catalog_listener_is_rejected_on_exact_legacy() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", legacy_public_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("legacy-only initializes the exact client");
let error = client
.open_subscriptions_listener(SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
})
.expect_err("legacy has no final subscription listener contract");
assert_eq!(error.code, McpErrorCode::InvalidParams);
client.ping().expect(
"the rejected incremental listener leaves exact legacy request state unchanged",
);
client.close().expect("legacy client cleanup");
}
#[cfg(unix)]
#[test]
#[cfg(feature = "tasks")]
fn clt_tasks_subscription_collects_only_acknowledged_exact_task_ids() {
let task_id = FinalTaskId::parse("task-73").expect("bounded task id");
let script =
modern_tasks_subscriptions_listen_client_script(task_id.as_str(), task_id.as_str());
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern Tasks discovery initializes the subscription client");
let mut filter = SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
};
fastmcp_protocol::set_task_subscription_ids(&mut filter, vec![task_id.clone()])
.expect("compose Tasks beside a core subscription filter");
let collector = client
.listen_subscriptions_typed(filter)
.expect("negotiated Tasks event remains request-owned and typed");
assert_eq!(collector.accepted_filter.tools_list_changed, Some(true));
assert!(collector.notifications.is_empty());
assert_eq!(collector.task_notifications.len(), 1);
assert_eq!(
collector.task_notifications[0].params.task.base().task_id,
task_id
);
client.close().expect("modern Tasks client cleanup");
}
#[cfg(unix)]
#[test]
#[cfg(feature = "tasks")]
fn live_final_tasks_listener_routes_acknowledgement_status_and_terminal_through_selected_stdio()
{
let task_id = FinalTaskId::parse("task-live-73").expect("bounded task id");
let script = live_tasks_listener_fixture(task_id.as_str(), task_id.as_str(), 2);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern Tasks discovery initializes the live listener client");
let mut filter = SubscriptionFilter::default();
fastmcp_protocol::set_task_subscription_ids(&mut filter, vec![task_id.clone()])
.expect("compose the exact live Tasks filter");
client
.open_final_task_subscription_listener(filter)
.expect("the public listener commits one selected-stdio subscription");
let cx = Cx::for_request();
let cancellation = McpRequestCancellation::new();
let acknowledged = client
.next_final_task_subscription_event(&cx, &cancellation)
.expect("public selected-stdio ingress routes the acknowledgement");
assert!(matches!(
acknowledged,
StdioTaskSubscriptionEvent::Acknowledged(ref filter)
if task_subscription_ids(filter)
.expect("acknowledged Tasks filter stays valid")
.as_deref()
== Some([task_id.clone()].as_slice())
));
let status = client
.next_final_task_subscription_event(&cx, &cancellation)
.expect("public selected-stdio ingress routes the matching task status");
assert!(matches!(
status,
StdioTaskSubscriptionEvent::Notification(notification)
if notification.params.task.base().task_id == task_id
));
assert!(matches!(
client
.next_final_task_subscription_event(&cx, &cancellation)
.expect("public selected-stdio ingress routes the terminal response"),
StdioTaskSubscriptionEvent::Terminal
));
assert!(
client.live_task_subscription.is_none(),
"terminal completion releases only the completed live listener"
);
client.close().expect("live listener client cleanup");
}
#[cfg(unix)]
#[test]
#[cfg(feature = "tasks")]
fn live_final_tasks_listener_rejects_one_field_foreign_task_id() {
let requested = FinalTaskId::parse("task-live-73").expect("bounded requested task id");
let foreign = FinalTaskId::parse("task-live-74").expect("bounded foreign task id");
let script = live_tasks_listener_fixture(requested.as_str(), foreign.as_str(), 2);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern Tasks discovery initializes the foreign-task listener client");
let mut filter = SubscriptionFilter::default();
fastmcp_protocol::set_task_subscription_ids(&mut filter, vec![requested])
.expect("compose the exact live Tasks filter");
client
.open_final_task_subscription_listener(filter)
.expect("the live listener commits before the foreign event arrives");
let cx = Cx::for_request();
let cancellation = McpRequestCancellation::new();
assert!(matches!(
client
.next_final_task_subscription_event(&cx, &cancellation)
.expect("the matching acknowledgement is delivered first"),
StdioTaskSubscriptionEvent::Acknowledged(_)
));
let error = client
.next_final_task_subscription_event(&cx, &cancellation)
.expect_err("one changed taskId must fail closed at selected-stdio ingress");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert_eq!(
error.message,
"Tasks subscription event taskId is outside the acknowledged filter"
);
}
#[cfg(unix)]
#[test]
#[cfg(feature = "tasks")]
fn live_final_tasks_listener_rejects_one_field_foreign_subscription_id() {
let task_id = FinalTaskId::parse("task-live-73").expect("bounded task id");
let script = live_tasks_listener_fixture(task_id.as_str(), task_id.as_str(), 3);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern Tasks discovery initializes the foreign-subscription listener client");
let mut filter = SubscriptionFilter::default();
fastmcp_protocol::set_task_subscription_ids(&mut filter, vec![task_id])
.expect("compose the exact live Tasks filter");
client
.open_final_task_subscription_listener(filter)
.expect("the live listener commits before the foreign stream arrives");
let cx = Cx::for_request();
let cancellation = McpRequestCancellation::new();
let error = client
.next_final_task_subscription_event(&cx, &cancellation)
.expect_err("a foreign subscription ID cannot acknowledge the live listener");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert_eq!(
error.message,
"Tasks subscription terminated before acknowledgement"
);
}
#[cfg(unix)]
#[test]
#[cfg(feature = "tasks")]
fn live_final_tasks_listener_retains_owner_when_cancellation_control_cannot_commit() {
let task_id = FinalTaskId::parse("task-live-73").expect("bounded task id");
let script = live_tasks_listener_fixture(task_id.as_str(), task_id.as_str(), 2);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern Tasks discovery initializes the cancellation listener client");
let mut filter = SubscriptionFilter::default();
fastmcp_protocol::set_task_subscription_ids(&mut filter, vec![task_id])
.expect("compose the exact live Tasks filter");
client
.open_final_task_subscription_listener(filter)
.expect("the live listener commits before cancellation");
client
.close_transport()
.expect("test closes the upstream writer before cancellation");
let cx = Cx::for_request();
let cancellation = McpRequestCancellation::new();
assert!(cancellation.cancel());
let first_error = client
.next_final_task_subscription_event(&cx, &cancellation)
.expect_err("an uncommitted upstream cancellation must be reported");
assert_ne!(first_error.code, McpErrorCode::RequestCancelled);
assert!(
client.live_task_subscription.is_some(),
"a failed cancellation must retain the live listener owner"
);
let second_error = client
.next_final_task_subscription_event(&cx, &cancellation)
.expect_err("the retained listener must preserve its original cancellation failure");
assert_eq!(second_error.code, first_error.code);
assert_eq!(second_error.message, first_error.message);
assert!(
client.live_task_subscription.is_some(),
"re-observing failure must not silently discard listener ownership"
);
let _ = client.close();
}
#[cfg(unix)]
#[test]
#[cfg(feature = "tasks")]
fn clt_tasks_subscription_rejects_one_field_unacknowledged_task_id() {
let requested = FinalTaskId::parse("task-73").expect("bounded requested task id");
let foreign = FinalTaskId::parse("task-74").expect("bounded foreign task id");
let script =
modern_tasks_subscriptions_listen_client_script(requested.as_str(), foreign.as_str());
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern Tasks discovery initializes the subscription client");
let mut filter = SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
};
fastmcp_protocol::set_task_subscription_ids(&mut filter, vec![requested])
.expect("compose one exact Tasks filter");
let error = client
.listen_subscriptions_typed(filter)
.expect_err("one changed taskId must fail the acknowledged stream closed");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert_eq!(
error.message,
"Tasks event taskId is outside the acknowledged filter"
);
assert!(!client.is_initialized());
}
#[cfg(unix)]
#[test]
fn clt_01_subscriptions_listen_rejects_one_field_acknowledgement_id_mismatch() {
// This differs from the admitted stream only in the acknowledgement subscription ID.
let script = modern_subscriptions_listen_client_script(
3,
&[
r#"{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}"#,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/subscriptionId":2}}}"#,
],
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the subscription client");
let error = client
.listen_subscriptions_typed(SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
})
.expect_err("a subscription acknowledgement must bind the active listen request");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(!client.is_initialized());
assert!(client.responses.terminal_error().is_some());
}
#[cfg(unix)]
#[test]
fn clt_01_subscriptions_listen_notification_overflow_fails_closed() {
let stream_frames = vec![
r#"{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}"#;
MAX_QUEUED_FINAL_SERVER_NOTIFICATIONS + 1
];
let script = modern_subscriptions_listen_client_script(2, &stream_frames);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the subscription client");
let error = client
.listen_subscriptions_typed(SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
})
.expect_err("subscription-owned notification retention must use the final queue bound");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert_eq!(
error.message,
FINAL_SERVER_NOTIFICATION_QUEUE_OVERFLOW_ERROR
);
assert!(!client.is_initialized());
assert!(client.responses.terminal_error().is_some());
assert!(client.take_final_server_notifications().is_empty());
}
#[cfg(unix)]
#[test]
fn clt_01_subscriptions_listen_cancellation_requires_terminal_complete_result() {
let script = modern_subscriptions_listen_client_script(
2,
&[
r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":2e0,"_meta":{"io.modelcontextprotocol/subscriptionId":2.0}}}"#,
],
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the subscription client");
let error = client
.listen_subscriptions_typed(SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
})
.expect_err(
"matching cancellation cannot replace the required terminal complete result",
);
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert_eq!(
error.message,
"Subscription listener reached EOF after cancellation before terminal complete result"
);
assert!(!client.is_initialized());
assert!(client.responses.terminal_error().is_some());
}
#[cfg(unix)]
#[test]
fn clt_01_subscriptions_listen_ignores_invalid_or_foreign_cancellation_controls() {
let script = modern_subscriptions_listen_client_script(
2,
&[
r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":null}}"#,
r#"{"jsonrpc":"2.0","id":99,"method":"notifications/cancelled","params":{"requestId":2}}"#,
r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":2,"reason":null}}"#,
r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":3}}"#,
r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":2,"_meta":{"io.modelcontextprotocol/subscriptionId":3}}}"#,
r#"{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}"#,
r#"{"jsonrpc":"2.0","id":2e0,"result":{"resultType":"complete","_meta":{"io.modelcontextprotocol/subscriptionId":2.0}}}"#,
],
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the subscription client");
let collector = client
.listen_subscriptions_typed(SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
})
.expect("invalid and foreign controls are inert for the owned live listener");
assert!(matches!(
collector.notifications.as_slice(),
[ServerNotification::ToolsListChanged(None)]
));
assert_eq!(client.responses.pending_len(), 0);
assert_eq!(client.responses.tombstone_len(), 0);
assert_eq!(client.responses.uncorrelated_diagnostics(), 0);
assert!(client.is_initialized());
assert!(client.responses.terminal_error().is_none());
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_subscriptions_listen_cancellation_consumes_terminal_before_next_request() {
let script = modern_subscription_cancellation_late_terminal_client_script();
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the subscription client");
let collector = client
.listen_subscriptions_typed(SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
})
.expect("matching cancellation remains live until its correlated complete result");
assert_eq!(collector.subscription_id, RequestId::from(2));
assert!(matches!(
collector.terminal.payload,
FinalSubscriptionsListenResult {}
));
assert_eq!(client.responses.pending_len(), 0);
assert_eq!(client.responses.tombstone_len(), 0);
assert_eq!(client.responses.uncorrelated_diagnostics(), 0);
client.list_tools_typed(None).expect(
"the next request starts after the listener consumed its own terminal response",
);
assert_eq!(client.responses.tombstone_len(), 0);
assert_eq!(client.responses.uncorrelated_diagnostics(), 0);
assert!(client.is_initialized());
assert!(client.responses.terminal_error().is_none());
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_subscriptions_listen_rejects_eof_before_terminal_complete_result() {
let script = modern_subscriptions_listen_client_script(2, &[]);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the subscription client");
let error = client
.listen_subscriptions_typed(SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
})
.expect_err("EOF cannot replace the final complete result");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert_eq!(
error.message,
"Subscription listener reached EOF before terminal complete result"
);
assert!(!client.is_initialized());
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn leg_03_subscriptions_listen_is_rejected_without_mutating_legacy_request_state() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", legacy_public_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("legacy-only initializes the exact client");
let error = client
.listen_subscriptions_typed(SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
})
.expect_err("legacy has no final subscription listener contract");
assert_eq!(error.code, McpErrorCode::InvalidParams);
client
.ping()
.expect("the rejected listener leaves the exact legacy request state unchanged");
client.close().expect("legacy client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_remaining_typed_list_result_positive() {
let script = modern_typed_list_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","tools":[],"ttlMs":0,"cacheScope":"private"}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the public client");
assert!(matches!(
client
.list_tools_typed(None)
.expect("typed tools/list accepts a complete final result"),
CoreResult::Final(FinalCoreResult::ToolsList { .. })
));
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[test]
fn cache_03_final_tools_list_replays_the_complete_result_without_a_second_request() {
let script = modern_typed_list_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","tools":[],"ttlMs":1000,"cacheScope":"private","x-retained":9007199254740993123456789}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the cache client");
client
.set_request_timeout_policy(
RequestTimeoutPolicy::new(Duration::from_millis(10), Duration::from_millis(10))
.expect("short test policy is valid"),
)
.expect("cache test policy is accepted");
let first = client
.list_tools_typed(None)
.expect("first tools/list response fills the final cache");
let second = client
.list_tools_typed(None)
.expect("fresh final cache hit avoids a second peer request");
assert_eq!(
first.encode().expect("first complete result re-encodes"),
second.encode().expect("cached complete result re-encodes"),
"the cached result retains unknown members and their exact number spelling"
);
assert_eq!(client.final_result_cache_stats().hits, 1);
assert_eq!(client.final_result_cache_stats().fills, 1);
client.close().expect("modern cache client cleanup");
}
#[cfg(unix)]
#[test]
fn cache_03_list_change_during_fetch_discards_the_late_cache_fill() {
let discovery = modern_discovery_response(
"cache-invalidation-modern-server",
&[MODERN_PROTOCOL_VERSION],
);
let script = format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery}' ;; *) exit 1 ;; esac; \
IFS= read -r second || exit 1; \
case \"$second\" in *tools/list*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}}'; \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"ttlMs\":1000,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r third || exit 1; \
case \"$third\" in *tools/list*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"ttlMs\":1000,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the invalidation client");
client
.list_tools_typed(None)
.expect("the first tools/list completes after the change notification");
client
.list_tools_typed(None)
.expect("the invalidated first fill requires a fresh second request");
assert_eq!(client.final_result_cache_stats().fills, 1);
assert_eq!(client.final_result_cache_stats().hits, 0);
assert!(matches!(
client.take_final_server_notifications().as_slice(),
[ServerNotification::ToolsListChanged(None)]
));
client.close().expect("modern invalidation client cleanup");
}
#[cfg(unix)]
#[test]
fn cache_03_idle_list_change_is_drained_before_a_fresh_hit() {
let discovery = modern_discovery_response(
"cache-idle-invalidation-modern-server",
&[MODERN_PROTOCOL_VERSION],
);
let script = format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery}' ;; *) exit 1 ;; esac; \
IFS= read -r second || exit 1; \
case \"$second\" in *tools/list*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"ttlMs\":1000,\"cacheScope\":\"private\"}}}}'; \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}}' ;; *) exit 1 ;; esac; \
IFS= read -r third || exit 1; \
case \"$third\" in *tools/list*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"ttlMs\":1000,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the idle-invalidation client");
client
.list_tools_typed(None)
.expect("first tools/list fills the local cache");
client
.list_tools_typed(None)
.expect("idle list-change notification forces a new tools/list request");
assert_eq!(client.final_result_cache_stats().hits, 0);
assert_eq!(client.final_result_cache_stats().fills, 2);
assert!(matches!(
client.take_final_server_notifications().as_slice(),
[ServerNotification::ToolsListChanged(None)]
));
client
.close()
.expect("modern idle-invalidation client cleanup");
}
#[cfg(unix)]
#[test]
fn cache_03_cached_hit_observes_client_cancellation() {
let script = modern_typed_list_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","tools":[],"ttlMs":1000,"cacheScope":"private"}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the cancellation client");
client
.list_tools_typed(None)
.expect("first tools/list fills the cache");
client.cx.set_cancel_requested(true);
let error = client
.list_tools_typed(None)
.expect_err("a cached hit must not bypass cancellation");
assert_eq!(error.code, McpErrorCode::RequestCancelled);
assert!(client.is_initialized());
assert!(client.responses.terminal_error().is_none());
}
#[cfg(unix)]
#[test]
fn cache_03_invalid_ttls_are_immediately_stale_and_do_not_close_the_client() {
let discovery = modern_discovery_response(
"cache-ttl-compatibility-modern-server",
&[MODERN_PROTOCOL_VERSION],
);
let script = format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery}' ;; *) exit 1 ;; esac; \
IFS= read -r second || exit 1; \
case \"$second\" in *tools/list*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r third || exit 1; \
case \"$third\" in *tools/list*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"ttlMs\":-1.5,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r fourth || exit 1; \
case \"$fourth\" in *tools/call*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":4,\"result\":{{\"resultType\":\"complete\",\"content\":[],\"isError\":false}}}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the TTL compatibility client");
client
.list_tools_typed(None)
.expect("a missing TTL is returned as immediately stale");
client
.list_tools_typed(None)
.expect("a negative TTL is returned as immediately stale");
client
.call_tool_typed("health", serde_json::json!({}))
.expect("TTL compatibility leaves the modern connection usable");
assert_eq!(
client.take_final_cache_ttl_diagnostics(),
vec![
FinalCacheTtlDiagnostic::Missing,
FinalCacheTtlDiagnostic::Negative,
]
);
assert_eq!(client.final_result_cache_stats().hits, 0);
assert_eq!(client.final_result_cache_stats().fills, 0);
assert!(client.is_initialized());
client.close().expect("modern TTL compatibility cleanup");
}
#[cfg(unix)]
#[test]
fn cache_03_cursorless_list_restarts_after_generation_drift() {
let discovery = modern_discovery_response(
"cache-list-restart-modern-server",
&[MODERN_PROTOCOL_VERSION],
);
let script = format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery}' ;; *) exit 1 ;; esac; \
IFS= read -r first_page || exit 1; \
case \"$first_page\" in *tools/list*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"nextCursor\":\"page-2\",\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r second_page || exit 1; \
case \"$second_page\" in *tools/list*'\"cursor\":\"page-2\"'*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}}'; \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r restarted || exit 1; \
case \"$restarted\" in *tools/list*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":4,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the list-restart client");
assert!(
client
.list_tools()
.expect("a generation drift restarts from a cursorless page")
.is_empty()
);
assert!(matches!(
client.take_final_server_notifications().as_slice(),
[ServerNotification::ToolsListChanged(None)]
));
client.close().expect("modern list-restart cleanup");
}
#[cfg(unix)]
#[test]
fn cache_03_disabled_cache_restarts_a_cursorless_list_after_generation_drift() {
let discovery = modern_discovery_response(
"cache-disabled-list-restart-modern-server",
&[MODERN_PROTOCOL_VERSION],
);
let script = format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery}' ;; *) exit 1 ;; esac; \
IFS= read -r first_page || exit 1; \
case \"$first_page\" in *tools/list*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"tools\":[{{\"name\":\"stale\",\"inputSchema\":{{\"type\":\"object\"}}}}],\"nextCursor\":\"page-2\",\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r second_page || exit 1; \
case \"$second_page\" in *tools/list*'\"cursor\":\"page-2\"'*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"method\":\"notifications/tools/list_changed\"}}'; \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{{\"resultType\":\"complete\",\"tools\":[{{\"name\":\"mixed\",\"inputSchema\":{{\"type\":\"object\"}}}}],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r restarted || exit 1; \
case \"$restarted\" in *tools/list*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":4,\"result\":{{\"resultType\":\"complete\",\"tools\":[{{\"name\":\"fresh\",\"inputSchema\":{{\"type\":\"object\"}}}}],\"ttlMs\":0,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the disabled-cache list client");
client.set_final_result_cache_enabled(false);
let tools = client
.list_tools()
.expect("disabled caching still restarts the cursorless list after invalidation");
assert!(matches!(tools.as_slice(), [Tool { name, .. }] if name == "fresh"));
assert!(matches!(
client.take_final_server_notifications().as_slice(),
[ServerNotification::ToolsListChanged(None)]
));
client
.close()
.expect("modern disabled-cache list-restart cleanup");
}
#[cfg(unix)]
#[test]
fn cache_03_invalid_cursor_flushes_the_cached_result_set() {
let discovery = modern_discovery_response(
"cache-invalid-cursor-modern-server",
&[MODERN_PROTOCOL_VERSION],
);
let script = format!(
"IFS= read -r first || exit 1; \
case \"$first\" in *server/discover*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{discovery}' ;; *) exit 1 ;; esac; \
IFS= read -r first_page || exit 1; \
case \"$first_page\" in *tools/list*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"nextCursor\":\"page-2\",\"ttlMs\":1000,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r rejected_page || exit 1; \
case \"$rejected_page\" in *tools/list*'\"cursor\":\"page-2\"'*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":3,\"error\":{{\"code\":-32602,\"message\":\"invalid cursor\"}}}}' ;; *) exit 1 ;; esac; \
IFS= read -r restarted || exit 1; \
case \"$restarted\" in *tools/list*io.modelcontextprotocol/protocolVersion*2026-07-28*) \
printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":4,\"result\":{{\"resultType\":\"complete\",\"tools\":[],\"ttlMs\":1000,\"cacheScope\":\"private\"}}}}' ;; *) exit 1 ;; esac; \
exec sleep 2"
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the cursor-flush client");
client
.list_tools_typed(None)
.expect("the first cursorless page enters the cache");
let error = client
.list_tools_typed(Some("page-2"))
.expect_err("the server rejects the opaque cursor");
assert_eq!(error.code, McpErrorCode::InvalidParams);
client
.list_tools_typed(None)
.expect("cursor rejection flushes the earlier cached page");
assert_eq!(client.final_result_cache_stats().hits, 0);
client.close().expect("modern cursor-flush client cleanup");
}
#[test]
fn cache_03_scope_drift_forces_a_full_list_restart() {
let mut client = make_closed_client(false);
let result_set = FinalCacheResultSet::Tools;
let generation = client.final_result_cache.begin_fetch(&result_set);
let mut baseline = Some((generation, fastmcp_protocol::CacheScope::Private));
client.last_final_cache_page = Some(FinalCachePageState {
generation,
scope: fastmcp_protocol::CacheScope::Public,
miss: None,
});
assert!(client.final_list_restart_needed(&result_set, &mut baseline));
assert_ne!(
generation,
client.final_result_cache.begin_fetch(&result_set),
"scope drift advances the result-set generation before restart"
);
}
#[cfg(unix)]
#[test]
fn clt_01_modern_convenience_list_projects_representable_catalog() {
let script = modern_typed_list_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","tools":[{"name":"echo","description":"representable","inputSchema":{"type":"object"}}],"ttlMs":0,"cacheScope":"private"}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the public client");
let tools = client
.list_tools()
.expect("neutral private cache hints and a representable catalog project exactly");
assert!(matches!(
tools.as_slice(),
[Tool { name, description, icon: None, .. }]
if name == "echo" && description.as_deref() == Some("representable")
));
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_modern_convenience_list_rejects_cache_scope_loss() {
// This differs from the representable catalog only in cacheScope.
let script = modern_typed_list_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","tools":[{"name":"echo","description":"representable","inputSchema":{"type":"object"}}],"ttlMs":0,"cacheScope":"public"}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("same modern discovery initializes the public client");
let error = client
.list_tools()
.expect_err("the legacy convenience API cannot discard a public cache scope");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(client.is_initialized());
client
.close()
.expect("local projection rejection leaves the client usable");
}
#[test]
fn clt_01_final_catalog_projection_rejects_each_one_field_loss() {
let representable = serde_json::json!({
"uri": "file:///catalog.txt",
"name": "catalog",
"description": "representable",
});
let projected = final_resource_to_legacy(
serde_json::from_value::<fastmcp_protocol::FinalResource>(representable.clone())
.expect("representable final resource parses"),
)
.expect("representable final resource projects without loss");
assert_eq!(projected.name, "catalog");
for (field, value) in [
("title", serde_json::json!("Catalog")),
("annotations", serde_json::json!({"audience":["user"]})),
("size", serde_json::json!(42)),
("_meta", serde_json::json!({"io.fastmcp.retained":true})),
(
"icons",
serde_json::json!([{
"src":"https://example.com/catalog.svg",
"theme":"dark"
}]),
),
] {
let mut lossy = representable.clone();
lossy[field] = value;
let error = final_resource_to_legacy(
serde_json::from_value::<fastmcp_protocol::FinalResource>(lossy)
.expect("one final-only field still parses as a final resource"),
)
.expect_err("one unrepresentable final catalog field must fail closed");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
}
}
#[test]
fn clt_01_final_content_projection_preserves_legacy_open_fields() {
let representable = serde_json::json!({
"type": "text",
"text": "representable",
"annotations": {"audience": ["user"]},
"_meta": {"io.fastmcp.retained": true},
"io.fastmcp/extension": {"retained": true},
});
let projected = final_content_to_legacy(
serde_json::from_value::<ContentBlock>(representable.clone())
.expect("representable final content parses"),
)
.expect("legacy content preserves all representable open fields");
assert_eq!(
serde_json::to_value(projected).expect("projected text re-encodes"),
representable
);
let shadowed_text = ContentBlock::Text {
text: "representable".to_owned(),
annotations: None,
meta: None,
additional: std::collections::BTreeMap::from([(
"text".to_owned(),
serde_json::json!("shadow"),
)]),
};
let error = final_content_to_legacy(shadowed_text)
.expect_err("an open member may not shadow a declared legacy text field");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
let embedded_representable = serde_json::json!({
"type": "resource",
"annotations": {"audience": ["assistant"]},
"_meta": {"io.fastmcp.retained": true},
"io.fastmcp/extension": {"retained": true},
"resource": {
"uri": "file:///embedded.txt",
"text": "representable",
"_meta": {"io.fastmcp.retained": true},
"io.fastmcp/extension": {"retained": true},
},
});
let projected = final_content_to_legacy(
serde_json::from_value::<ContentBlock>(embedded_representable.clone())
.expect("representable embedded resource parses"),
)
.expect("legacy resource content preserves all representable open fields");
assert_eq!(
serde_json::to_value(projected).expect("projected resource re-encodes"),
embedded_representable
);
let unsupported = serde_json::json!({
"type": "audio",
"data": "AA==",
"mimeType": "audio/mpeg",
"annotations": {"audience": ["user"]},
"_meta": {"io.fastmcp.retained": true},
"io.fastmcp/extension": {"retained": true},
});
let error = final_content_to_legacy(
serde_json::from_value::<ContentBlock>(unsupported)
.expect("valid final audio content parses before projection"),
)
.expect_err("an exact legacy result cannot represent final audio content");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
}
#[test]
fn leg_03_convenience_results_retain_exact_nested_open_fields() {
let resource = LegacyResourceContent::Text {
uri: "file:///legacy.txt".to_owned(),
text: "legacy resource".to_owned(),
mime_type: Some("text/plain".to_owned()),
additional: std::collections::BTreeMap::from([
("_meta".to_owned(), serde_json::json!({"vendor": true})),
(
"io.fastmcp/extension".to_owned(),
serde_json::json!({"retained": true}),
),
]),
};
let resources = convenience_resource_read(CoreResult::Legacy(
LegacyCoreResult::ResourcesRead(fastmcp_protocol::ReadResourceResult {
contents: vec![resource.clone()],
meta: None,
additional: std::collections::BTreeMap::new(),
}),
))
.expect("legacy resource convenience result retains its exact resource shape");
assert_eq!(resources, vec![resource]);
let message = LegacyPromptMessage {
role: fastmcp_protocol::Role::User,
content: LegacyContent::Text {
text: "legacy prompt".to_owned(),
annotations: None,
additional: std::collections::BTreeMap::from([(
"_meta".to_owned(),
serde_json::json!({"vendor": true}),
)]),
},
additional: std::collections::BTreeMap::from([(
"io.fastmcp/extension".to_owned(),
serde_json::json!({"retained": true}),
)]),
};
let messages = convenience_prompt_get(CoreResult::Legacy(LegacyCoreResult::PromptsGet(
fastmcp_protocol::GetPromptResult {
description: None,
messages: vec![message.clone()],
meta: None,
additional: std::collections::BTreeMap::new(),
},
)))
.expect("legacy prompt convenience result retains its exact message shape");
assert_eq!(messages, vec![message]);
}
#[cfg(unix)]
#[test]
fn clt_01_remaining_typed_list_null_discriminator_rejected() {
// This differs from the accepted typed list result only in
// `resultType`.
let script = modern_typed_list_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":null,"tools":[],"ttlMs":0,"cacheScope":"private"}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("same modern discovery initializes the public client");
let error = client
.list_tools_typed(None)
.expect_err("an explicit null discriminator is not an omitted complete discriminator");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(!client.is_initialized());
assert!(client.responses.terminal_error().is_some());
}
#[cfg(unix)]
#[test]
fn clt_01_final_typed_client_result_null_discriminator_rejected() {
// This differs from the accepted modern result only in `resultType`.
let script = modern_typed_call_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":null,"content":[{"type":"text","text":"typed result"}],"isError":false}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("same modern discovery initializes the public client");
let error = client
.call_tool_typed("echo", serde_json::json!({"text": "typed"}))
.expect_err("an explicit null discriminator is not an omitted complete discriminator");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(!client.is_initialized());
assert!(client.responses.terminal_error().is_some());
}
#[cfg(unix)]
#[test]
fn clt_01_completion_client_result_positive() {
let script = modern_completion_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","completion":{"values":["staging"],"total":922337203685477580812345678901234567890,"hasMore":false}}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the public client");
let result = client
.complete(modern_completion_params())
.expect("modern completion returns its typed final payload");
let CoreResult::Final(FinalCoreResult::Completion { result, diagnostic }) = result else {
panic!("modern completion must not decode through the legacy result shape");
};
assert!(diagnostic.is_none());
assert_eq!(result.payload.completion.values, vec!["staging".to_owned()]);
let expected_total =
serde_json::from_str::<JsonInteger>("922337203685477580812345678901234567890")
.expect("arbitrary-precision completion total is an exact JSON integer");
assert_eq!(result.payload.completion.total, Some(expected_total));
assert_eq!(result.payload.completion.has_more, Some(false));
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_completion_client_result_null_discriminator_rejected() {
// This differs from the accepted completion result only in `resultType`.
let script = modern_completion_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":null,"completion":{"values":["staging"],"total":1,"hasMore":false}}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("same modern discovery initializes the public client");
let error = client
.complete(modern_completion_params())
.expect_err("an explicit null completion discriminator is rejected");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(!client.is_initialized());
assert!(client.responses.terminal_error().is_some());
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_02_auto_modern_completion_retains_full_context() {
let script = modern_completion_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","completion":{"values":["staging"],"total":1,"hasMore":false}}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::Auto),
Cx::for_request(),
)
.expect("Auto retains a successful modern selection");
let result = client
.complete(modern_completion_params())
.expect("Auto-modern completion transmits the full final context");
assert_eq!(client.protocol_policy(), ProtocolPolicy::Auto);
assert_eq!(
client.selected_protocol_era(),
Some(ProtocolEra::Modern2026)
);
assert!(matches!(
result,
CoreResult::Final(FinalCoreResult::Completion { .. })
));
client.close().expect("auto modern cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_02_auto_legacy_completion_losslessly_maps_compatible_input() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", auto_legacy_completion_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::Auto),
Cx::for_request(),
)
.expect("recognized modern refusal authorizes one exact legacy selection");
let result = client
.complete(completion_params())
.expect("title-free, context-free completion maps to exact legacy");
assert_eq!(client.protocol_policy(), ProtocolPolicy::Auto);
assert_eq!(
client.selected_protocol_era(),
Some(ProtocolEra::Legacy2024)
);
assert!(matches!(
result,
CoreResult::Legacy(LegacyCoreResult::Completion(_))
));
client.close().expect("auto legacy cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_02_auto_legacy_completion_rejects_unrepresentable_context_without_sending() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", auto_legacy_completion_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::Auto),
Cx::for_request(),
)
.expect("recognized modern refusal authorizes one exact legacy selection");
let error = client
.complete(completion_params_with_context())
.expect_err("legacy completion must not erase final-only context");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert!(client.is_initialized());
assert!(matches!(
client
.complete(completion_params())
.expect("rejection leaves the exact legacy request state unchanged"),
CoreResult::Legacy(LegacyCoreResult::Completion(_))
));
client.close().expect("auto legacy cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_progress_client_result_positive() {
let script = modern_progress_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","content":[{"type":"text","text":"progress result"}],"isError":false}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the public client");
let mut observed_progress = Vec::new();
let mut on_progress = |progress: f64, total: Option<f64>, message: Option<&str>| {
observed_progress.push((progress, total, message.map(ToOwned::to_owned)));
};
let content = client
.call_tool_with_progress(
"echo",
serde_json::json!({"text": "progress"}),
&mut on_progress,
)
.expect("progress calls admit the same negotiated complete result");
assert_eq!(content.len(), 1);
assert_eq!(
observed_progress,
vec![(0.5, Some(1.0), Some("modern progress".to_owned()))]
);
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_final_progress_queue_preserves_decimal_exponent_positive() {
let script = modern_server_notification_client_script(
r#"{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":2,"progress":1e400,"total":1e401,"message":"exact progress"}}"#,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","content":[{"type":"text","text":"progress result"}],"isError":false}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the public client");
let mut legacy_progress = Vec::new();
let mut on_progress = |progress: f64, total: Option<f64>, message: Option<&str>| {
legacy_progress.push((progress, total, message.map(ToOwned::to_owned)));
};
client
.call_tool_with_progress(
"echo",
serde_json::json!({"text": "exact progress"}),
&mut on_progress,
)
.expect("an exact final progress value does not lose its following response");
assert!(
legacy_progress.is_empty(),
"the legacy f64 callback must not receive an unrepresentable value"
);
let progress = client.take_final_progress_notifications();
assert!(matches!(
progress.as_slice(),
[params]
if params.progress.as_str() == "1e400"
&& params.total.as_ref().is_some_and(|total| total.as_str() == "1e401")
&& params.message.as_deref() == Some("exact progress")
));
assert!(client.take_final_progress_notifications().is_empty());
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_final_progress_queue_rejects_total_below_progress() {
// The final schema deliberately leaves progress and total unordered;
// this differs from the baseline only by making total smaller.
let script = modern_server_notification_client_script(
r#"{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":2,"progress":1e400,"total":9e399,"message":"exact progress"}}"#,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","content":[{"type":"text","text":"progress result"}],"isError":false}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("same modern discovery initializes the public client");
let mut on_progress = |_progress: f64, _total: Option<f64>, _message: Option<&str>| {};
client
.call_tool_with_progress(
"echo",
serde_json::json!({"text": "exact progress"}),
&mut on_progress,
)
.expect("final progress greater than total remains schema-valid");
let progress = client.take_final_progress_notifications();
assert!(matches!(
progress.as_slice(),
[params]
if params.progress.as_str() == "1e400"
&& params.total.as_ref().is_some_and(|total| total.as_str() == "9e399")
));
assert!(client.is_initialized());
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_progress_client_result_null_discriminator_rejected() {
// This differs from the accepted progress response only in `resultType`.
let script = modern_progress_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":null,"content":[{"type":"text","text":"progress result"}],"isError":false}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("same modern discovery initializes the public client");
let mut on_progress = |_progress: f64, _total: Option<f64>, _message: Option<&str>| {};
let error = client
.call_tool_with_progress(
"echo",
serde_json::json!({"text": "progress"}),
&mut on_progress,
)
.expect_err("an explicit null discriminator is rejected after progress admission");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(!client.is_initialized());
assert!(client.responses.terminal_error().is_some());
}
#[cfg(unix)]
#[test]
fn clt_01_final_server_notifications_are_typed_and_drained() {
let script = modern_server_notification_client_script(
r#"{"jsonrpc":"2.0","method":"notifications/resources/updated","params":{"uri":"file:///workspace/guide.md","_meta":{"com.example/trace":"retained"}}}"#,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","content":[{"type":"text","text":"notification result"}],"isError":false}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the public client");
let content = client
.call_tool("echo", serde_json::json!({"text": "notification"}))
.expect("the typed notification must not consume its following response");
assert_eq!(content.len(), 1);
let notifications = client.take_final_server_notifications();
assert_eq!(notifications.len(), 1);
assert!(matches!(
¬ifications[0],
ServerNotification::ResourceUpdated(params)
if params.uri.as_str() == "file:///workspace/guide.md"
&& params.meta.as_ref().and_then(|meta| meta.get("com.example/trace"))
== Some(&serde_json::json!("retained"))
));
assert!(client.take_final_server_notifications().is_empty());
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_final_log_message_is_retained_after_sink_projection() {
let script = modern_server_notification_client_script(
r#"{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"warning","logger":"server.audit","data":{"event":"tool-complete"},"_meta":{"com.example/trace":"retained"},"com.example/extension":true}}"#,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","content":[{"type":"text","text":"notification result"}],"isError":false}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the public client");
client
.call_tool("echo", serde_json::json!({"text": "notification"}))
.expect("the final log message must not consume its following response");
let notifications = client.take_final_server_notifications();
assert!(matches!(
notifications.as_slice(),
[ServerNotification::Message(message)]
if message.level == LoggingLevel::Warning
&& message.logger.as_deref() == Some("server.audit")
&& message.data == serde_json::json!({"event": "tool-complete"})
&& message.meta.as_ref().and_then(|meta| meta.get("com.example/trace"))
== Some(&serde_json::json!("retained"))
&& message.additional.get("com.example/extension")
== Some(&serde_json::json!(true))
));
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[test]
fn clt_01_final_server_notification_null_uri_fails_closed() {
// This differs from the accepted notification only in the required URI.
let script = modern_server_notification_client_script(
r#"{"jsonrpc":"2.0","method":"notifications/resources/updated","params":{"uri":null,"_meta":{"com.example/trace":"retained"}}}"#,
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","content":[{"type":"text","text":"notification result"}],"isError":false}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("same modern discovery initializes the public client");
let error = client
.call_tool("echo", serde_json::json!({"text": "notification"}))
.expect_err("a malformed final notification must fail the modern connection");
assert_eq!(error.code, McpErrorCode::InvalidRequest);
assert!(!client.is_initialized());
assert!(client.responses.terminal_error().is_some());
assert!(client.take_final_server_notifications().is_empty());
}
#[cfg(unix)]
#[test]
fn clt_01_j_positive() {
let modern_result = modern_discovery_response_with_final_state("stateful-modern", "public");
let script = modern_public_client_script(&modern_result);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery retains its final session state");
let discovery = client
.server_discovery()
.expect("modern session exposes the exact discovery result");
let discovered_server = discovery
.server_info()
.expect("modern discovery retains server identity");
assert_eq!(discovered_server.name, client.server_info().name);
assert_eq!(discovered_server.version, client.server_info().version);
assert_eq!(
discovery
.instructions()
.expect("peer instructions are retained")
.as_str(),
"use the final contract"
);
assert_eq!(discovery.cache_hints().ttl_ms().as_str(), "73");
assert_eq!(
discovery
.cache_hints()
.ttl_ms()
.try_as_millis()
.expect("discovery TTL fits the local duration domain"),
73
);
assert!(discovery.cache_hints().is_public());
let retained = serde_json::to_value(discovery)
.expect("the retained final discovery result stays serializable");
assert_eq!(
retained["capabilities"]["extensions"]["com.example/session-state"],
serde_json::json!({ "mode": "lossless" })
);
assert_eq!(
retained["_meta"]["com.example/session-state"],
serde_json::json!({ "origin": "peer" })
);
client.close().expect("stateful modern client cleanup");
}
#[cfg(unix)]
#[test]
#[allow(clippy::err_expect)] // Client deliberately has no Debug surface
fn clt_01_j_planted_negative() {
// This differs from the positive discovery response only in the final
// cache scope. It must be rejected rather than silently replaced by
// legacy-default cache state.
let modern_result =
modern_discovery_response_with_final_state("stateful-modern", "not-a-cache-scope");
let script = modern_public_client_script(&modern_result);
let error = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.err()
.expect("invalid final cache semantics must reject the modern session");
assert_eq!(error.code, McpErrorCode::InternalError);
}
#[test]
#[allow(clippy::err_expect)] // Client deliberately has no Debug surface
fn clt_01_i_planted_negative() {
// Only the discovery result's advertised version differs from the
// accepted modern path. A malformed modern success may not turn into
// legacy initialization or a second execution path.
let legacy_advertisement = modern_discovery_response("modern-server", &[PROTOCOL_VERSION]);
let script = modern_public_client_script(&legacy_advertisement);
let error = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.err()
.expect("modern-only must reject a legacy-only discovery success");
assert_eq!(error.code, McpErrorCode::InternalError);
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_02_i_positive() {
let modern_result =
modern_discovery_response("auto-modern-server", &[MODERN_PROTOCOL_VERSION]);
let script = modern_public_client_script(&modern_result);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::Auto),
Cx::for_request(),
)
.expect("auto retains a successful modern selection");
assert_eq!(client.protocol_policy(), ProtocolPolicy::Auto);
assert_eq!(
client.selected_protocol_era(),
Some(ProtocolEra::Modern2026)
);
assert!(
client.server_discovery().is_some(),
"Auto plan replacement retains the completed modern discovery result"
);
assert!(matches!(
client
.list_tools_typed(None)
.expect("auto-selected modern client executes normally"),
CoreResult::Final(FinalCoreResult::ToolsList { .. })
));
client.close().expect("auto modern cleanup");
}
#[cfg(unix)]
#[test]
fn clt_02_discovery_absent_result_type_selects_modern_with_diagnostic() {
let baseline =
modern_discovery_response("compatibility-modern-server", &[MODERN_PROTOCOL_VERSION]);
let mut response: serde_json::Value =
serde_json::from_str(&baseline).expect("baseline discovery response is JSON");
response["result"]
.as_object_mut()
.expect("discovery result is an object")
.remove("resultType");
let response = serde_json::to_string(&response)
.expect("missing-discriminator discovery response re-encodes");
let script = modern_public_client_script(&response);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("an otherwise-valid missing discriminator establishes modern");
assert_eq!(
client.selected_protocol_era(),
Some(ProtocolEra::Modern2026)
);
let discovery = client
.server_discovery()
.expect("modern classification retains discovery evidence");
assert_eq!(discovery.result_type(), "complete");
assert_eq!(
discovery.peer_diagnostic(),
Some(fastmcp_protocol::ResultPeerDiagnostic::ModernMissingResultType)
);
let retained = serde_json::to_value(discovery)
.expect("retained compatibility discovery remains serializable");
assert_eq!(
retained.get("resultType"),
Some(&serde_json::json!("complete")),
"re-emission canonicalizes the peer omission while the diagnostic retains evidence"
);
client.close().expect("compatibility modern cleanup");
}
#[allow(clippy::err_expect)] // Client deliberately has no Debug surface
#[cfg(unix)]
#[test]
fn clt_02_discovery_non_complete_result_types_do_not_select_an_era() {
let baseline =
modern_discovery_response("invalid-discriminator-server", &[MODERN_PROTOCOL_VERSION]);
for planted_result_type in [
serde_json::json!("input_required"),
serde_json::json!("task"),
serde_json::json!("com.example/deferred-discovery"),
serde_json::Value::Null,
serde_json::json!({"complete": true}),
] {
let mut response: serde_json::Value =
serde_json::from_str(&baseline).expect("baseline discovery response is JSON");
response["result"]["resultType"] = planted_result_type;
let response = serde_json::to_string(&response)
.expect("invalid-discriminator response remains JSON");
let script = modern_public_client_script(&response);
let error = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.err()
.expect("every non-complete discriminator rejects discovery");
assert_eq!(error.code, McpErrorCode::InternalError);
}
for (member, value) in [
("requestState", serde_json::json!("resume-1")),
(
"inputRequests",
serde_json::json!({"roots": {"method": "roots/list"}}),
),
("taskId", serde_json::json!("task-1")),
] {
let mut response: serde_json::Value =
serde_json::from_str(&baseline).expect("baseline discovery response is JSON");
response["result"][member] = value;
let response = serde_json::to_string(&response)
.expect("contradictory discovery response remains JSON");
let script = modern_public_client_script(&response);
let error = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.err()
.expect("a contradictory final shape rejects discovery before negotiation");
assert_eq!(error.code, McpErrorCode::InternalError);
}
}
#[cfg(unix)]
#[allow(clippy::err_expect)] // Client deliberately has no Debug surface
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn clt_02_i_planted_negative() {
// Only the discovery result's version differs from the Auto positive.
// An invalid modern success is not an authorized fallback signal.
let legacy_advertisement =
modern_discovery_response("auto-modern-server", &[PROTOCOL_VERSION]);
let script = modern_public_client_script(&legacy_advertisement);
let error = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::Auto),
Cx::for_request(),
)
.err()
.expect("auto must not downgrade from a malformed modern discovery result");
assert_eq!(error.code, McpErrorCode::InternalError);
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn leg_03_i_positive() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", legacy_public_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("legacy-only runs exact initialize and lifecycle acknowledgement");
assert_eq!(client.protocol_policy(), ProtocolPolicy::LegacyOnly);
assert_eq!(
client.selected_protocol_era(),
Some(ProtocolEra::Legacy2024)
);
assert_eq!(client.protocol_version(), PROTOCOL_VERSION);
assert!(
client.server_discovery().is_none(),
"exact legacy initialization never fabricates final discovery state"
);
client
.ping()
.expect("legacy client executes after initialized notification");
client.close().expect("legacy client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn leg_03_ping_preserves_the_exact_legacy_request_path() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", legacy_public_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("legacy initialization succeeds before the exact ping request");
client
.ping()
.expect("legacy ping keeps its core acknowledgement and omits final metadata");
client.close().expect("legacy client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn leg_03_server_ping_succeeds_during_public_request() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", legacy_reverse_ping_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("legacy initialization succeeds before the reverse ping request");
client
.ping()
.expect("a selected exact legacy session acknowledges server-originated ping");
client.close().expect("legacy client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn leg_03_resource_subscriptions_use_the_exact_legacy_request_path() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", legacy_resource_subscription_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("legacy initialization succeeds before resource subscription requests");
client
.subscribe_resource_legacy("resource://test")
.expect("exact legacy resources/subscribe omits final metadata");
client
.unsubscribe_resource_legacy("resource://test")
.expect("exact legacy resources/unsubscribe omits final metadata");
client
.close()
.expect("legacy resource subscription cleanup");
}
#[cfg(unix)]
#[test]
fn leg_03_resource_subscriptions_reject_modern_before_request_mutation() {
let discovery = modern_discovery_response(
"modern-resource-subscriptions-server",
&[MODERN_PROTOCOL_VERSION],
);
let script = modern_public_client_script(&discovery);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes before legacy resource rejection");
let next_id_before = client.next_id.load(Ordering::SeqCst);
for operation in [
Client::subscribe_resource_legacy,
Client::unsubscribe_resource_legacy,
] {
let error = operation(&mut client, "resource://test")
.expect_err("a legacy resource subscription must not send in the modern era");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(client.next_id.load(Ordering::SeqCst), next_id_before);
assert!(client.is_initialized());
}
client
.close()
.expect("modern resource subscription cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
#[cfg(feature = "tasks")]
fn leg_03_final_tasks_rejects_exact_legacy_before_request_mutation() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", legacy_public_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("legacy initialization succeeds before final Tasks admission");
let next_id_before = client.next_id.load(Ordering::SeqCst);
let error = client
.get_task_final(FinalTaskId::parse("task-1").expect("typed final task ID"))
.expect_err("exact 2024-11-05 excludes the final Tasks extension");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(client.next_id.load(Ordering::SeqCst), next_id_before);
client.close().expect("legacy Tasks client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn leg_03_typed_client_result_preserves_exact_legacy_decode() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", legacy_typed_call_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("legacy-only runs exact initialize and lifecycle acknowledgement");
let result = client
.call_tool_typed("echo", serde_json::json!({"text": "legacy"}))
.expect("the exact legacy tools/call response remains accepted");
let CoreResult::Legacy(LegacyCoreResult::ToolsCall(result)) = result else {
panic!("legacy tools/call must not require a final result discriminator");
};
assert!(!result.is_error);
assert_eq!(result.content.len(), 1);
assert_eq!(
result
.meta
.as_ref()
.and_then(|meta| meta.get("io.fastmcp.result")),
Some(&serde_json::json!(true))
);
assert_eq!(
result.additional.get("io.fastmcp.resultExtension"),
Some(&serde_json::json!({"kept": true}))
);
client.close().expect("legacy client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn leg_03_exact_legacy_tool_convenience_retains_the_pinned_result() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", legacy_typed_call_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("legacy-only runs exact initialize and lifecycle acknowledgement");
let result = client
.call_tool_legacy("echo", serde_json::json!({"text": "legacy"}))
.expect("the legacy convenience retains the full exact result");
assert_eq!(
result
.meta
.as_ref()
.and_then(|meta| meta.get("io.fastmcp.result")),
Some(&serde_json::json!(true))
);
assert_eq!(
result.additional.get("io.fastmcp.resultExtension"),
Some(&serde_json::json!({"kept": true}))
);
let [LegacyContent::Text { additional, .. }] = result.content.as_slice() else {
panic!("the exact legacy result must retain its text content");
};
assert_eq!(
additional.get("io.fastmcp/extension"),
Some(&serde_json::json!({"kept": true}))
);
client.close().expect("legacy client cleanup");
}
#[cfg(unix)]
#[test]
fn leg_03_exact_legacy_tool_convenience_rejects_a_modern_session_before_request_mutation() {
let script = modern_typed_call_client_script(
r#"{"jsonrpc":"2.0","id":2,"result":{"resultType":"complete","content":[{"type":"text","text":"modern result"}],"isError":false}}"#,
);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.expect("modern discovery initializes the final client");
let next_id_before = client.next_id.load(Ordering::SeqCst);
let error = client
.call_tool_legacy("echo", serde_json::json!({"text": "modern"}))
.expect_err("changing only the selected era cannot project a final result to legacy");
assert_eq!(error.code, McpErrorCode::InvalidParams);
assert_eq!(client.next_id.load(Ordering::SeqCst), next_id_before);
client.close().expect("modern client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn leg_03_convenience_tool_preserves_exact_legacy_decode() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", legacy_typed_call_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("legacy-only runs exact initialize and lifecycle acknowledgement");
let content = client
.call_tool("echo", serde_json::json!({"text": "legacy"}))
.expect("the convenience API retains the exact legacy result shape");
assert!(matches!(
content.as_slice(),
[LegacyContent::Text { text, .. }] if text == "legacy result"
));
let encoded = serde_json::to_value(content).expect("legacy content re-encodes exactly");
assert_eq!(
encoded[0]["annotations"]["audience"],
serde_json::json!(["user"])
);
assert_eq!(
encoded[0]["_meta"]["io.fastmcp.legacy"],
serde_json::json!(true)
);
assert_eq!(
encoded[0]["io.fastmcp/extension"],
serde_json::json!({"kept": true})
);
client.close().expect("legacy client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn leg_03_remaining_typed_list_preserves_exact_legacy_decode() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", legacy_typed_list_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("legacy-only runs exact initialize and lifecycle acknowledgement");
assert!(matches!(
client
.list_tools_typed(None)
.expect("the exact legacy tools/list response remains accepted"),
CoreResult::Legacy(LegacyCoreResult::ToolsList(_))
));
client.close().expect("legacy client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn leg_03_progress_client_result_preserves_exact_legacy_decode() {
let script = legacy_progress_client_script(2);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("legacy-only runs exact initialize and lifecycle acknowledgement");
let mut observed_progress = Vec::new();
let mut on_progress = |progress: f64, total: Option<f64>, message: Option<&str>| {
observed_progress.push((progress, total, message.map(ToOwned::to_owned)));
};
let content = client
.call_tool_with_progress(
"echo",
serde_json::json!({"text": "legacy progress"}),
&mut on_progress,
)
.expect("legacy progress calls do not require a final result discriminator");
assert_eq!(content.len(), 1);
assert_eq!(
observed_progress,
vec![(0.5, Some(1.0), Some("legacy progress".to_owned()))]
);
client.close().expect("legacy client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn leg_03_progress_nonmatching_token_leaves_callback_state_unchanged() {
let script = legacy_progress_client_script(3);
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", script.as_str()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("legacy-only runs exact initialize and lifecycle acknowledgement");
let mut observed_progress = Vec::new();
let mut on_progress = |progress: f64, total: Option<f64>, message: Option<&str>| {
observed_progress.push((progress, total, message.map(ToOwned::to_owned)));
};
let content = client
.call_tool_with_progress(
"echo",
serde_json::json!({"text": "legacy progress"}),
&mut on_progress,
)
.expect("a nonmatching progress token does not disturb the legacy request");
assert_eq!(content.len(), 1);
assert!(
observed_progress.is_empty(),
"the callback state must remain unchanged for a nonmatching token"
);
assert!(client.is_initialized());
client.close().expect("legacy client cleanup");
}
#[cfg(unix)]
#[cfg(feature = "legacy-2024-11-05")]
#[test]
fn leg_03_completion_client_result_preserves_exact_legacy_decode() {
let mut client = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", legacy_completion_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::LegacyOnly),
Cx::for_request(),
)
.expect("legacy-only runs exact initialize and lifecycle acknowledgement");
let result = client
.complete(completion_params())
.expect("legacy completion retains its exact response shape");
let CoreResult::Legacy(LegacyCoreResult::Completion(result)) = result else {
panic!("legacy completion must not require a final result discriminator");
};
assert_eq!(result.completion.values, vec!["staging".to_owned()]);
assert_eq!(
result.completion.total,
Some(1),
"legacy completion total remains a legacy machine integer"
);
assert_eq!(result.completion.has_more, Some(false));
client.close().expect("legacy client cleanup");
}
#[allow(clippy::err_expect)] // Client deliberately has no Debug surface
#[cfg(unix)]
#[test]
fn leg_03_i_planted_negative() {
// Only the immutable policy differs from the accepted legacy path.
// The modern probe is not permitted to reuse the 2024 lifecycle.
let error = Client::stdio_with_protocol_plan_with_cx(
"sh",
&["-c", legacy_public_client_script()],
ClientProtocolPlan::stdio(ProtocolPolicy::ModernOnly),
Cx::for_request(),
)
.err()
.expect("modern-only must reject a legacy-only peer before initialization");
assert_eq!(error.code, McpErrorCode::InternalError);
}
// ========================================
// Drop behavior
// ========================================
#[test]
fn uncertain_direct_child_probe_never_authorizes_termination() {
let probe: std::io::Result<Option<ExitStatus>> =
Err(std::io::Error::other("injected child-status uncertainty"));
assert_eq!(
direct_child_stop_decision(&probe),
DirectChildStopDecision::DoNotSignal
);
}
#[cfg(target_os = "linux")]
#[test]
fn child_guard_terminates_and_reaps_direct_child() {
let (child, stdout, stdin, pid) = spawn_long_running_child();
let guard = ChildGuard::new(child);
drop(guard);
drop(stdout);
drop(stdin);
wait_for_process_exit(pid);
}
#[cfg(target_os = "linux")]
#[test]
fn client_close_terminates_and_reaps_direct_child() {
let (child, stdout, stdin, pid) = spawn_long_running_child();
let transport = StdioTransport::new(stdout, stdin);
let session = ClientSession::try_new(
ClientInfo {
name: "cleanup-test".to_string(),
version: "1.0.0".to_string(),
},
ClientCapabilities::default(),
ServerInfo {
name: "direct-child".to_string(),
version: "1.0.0".to_string(),
},
ServerCapabilities::default(),
PROTOCOL_VERSION.to_string(),
)
.expect("test client uses the exact supported protocol version");
let mut client = Client::from_parts(
child,
transport,
Cx::for_request(),
session,
RequestTimeoutPolicy::default(),
);
client.close().expect("client cleanup");
wait_for_process_exit(pid);
}
#[cfg(target_os = "linux")]
#[test]
fn client_drop_terminates_and_reaps_live_direct_child() {
let (child, stdout, stdin, pid) = spawn_long_running_child();
let transport = StdioTransport::new(stdout, stdin);
let session = ClientSession::try_new(
ClientInfo {
name: "drop-cleanup-test".to_string(),
version: "1.0.0".to_string(),
},
ClientCapabilities::default(),
ServerInfo {
name: "live-direct-child".to_string(),
version: "1.0.0".to_string(),
},
ServerCapabilities::default(),
PROTOCOL_VERSION.to_string(),
)
.expect("test client uses the exact supported protocol version");
let client = Client::from_parts(
child,
transport,
Cx::for_request(),
session,
RequestTimeoutPolicy::default(),
);
drop(client);
wait_for_process_exit(pid);
}
#[test]
fn drop_cleans_up_subprocess() {
// Verify that dropping a client doesn't panic even for closed transport
let client = make_closed_client(true);
std::thread::sleep(Duration::from_millis(50));
drop(client);
// If we get here without panicking, the test passes
}
#[test]
fn client_progress_params_debug() {
let params = ClientProgressParams {
marker: ProgressMarker::Number(JsonInteger::from(1)),
progress: 0.5,
total: Some(1.0),
message: Some("half".into()),
meta: None,
};
let debug = format!("{:?}", params);
assert!(debug.contains("progress"));
}
#[test]
fn transport_error_to_mcp_preserves_io_details() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "socket vanished");
let mcp_err = transport_error_to_mcp(TransportError::Io(io_err));
assert!(mcp_err.message.contains("socket vanished"));
}
#[test]
fn method_not_found_response_error_message_redacts_method() {
let request = JsonRpcRequest::new("totally/custom/method", None, 1i64);
let response = method_not_found_response(&request).unwrap();
if let JsonRpcMessage::Response(resp) = response {
let error = resp.error.unwrap();
assert_eq!(error.message, "Method not found");
assert!(!error.message.contains("totally/custom/method"));
}
}
#[test]
fn client_server_capabilities_default_is_empty() {
let client = make_closed_client(true);
let caps = client.server_capabilities();
// Default capabilities should have no features enabled
assert!(caps.tools.is_none());
assert!(caps.resources.is_none());
assert!(caps.prompts.is_none());
}
}