//! Public modern HTTP round trip: shipped client against shipped server.
//!
//! Both ends of these tests are real shipped surfaces — the turnkey
//! `Server::bind_http`/`serve` lifecycle on one side and facade client
//! connection lifecycles on the other — joined over one real localhost socket.
//! The fixture uses the lower server builder only to select the explicit
//! Auto, ModernOnly, or LegacyOnly server policy under test; no scripted peer,
//! fixture transcript, or mock stands in for either endpoint.
//!
//! What this proves (and only this): the shipped dual-era HTTP server and
//! facade clients can complete Auto classification plus typed ModernOnly and
//! LegacyOnly tool, resource, and prompt round trips against each other. The
//! cross-era refusal cases additionally prove that a rejected connection does
//! not change later matched-era handler observables. It is not an aggregate
//! MCP 2026-07-28 conformance claim.
use std::collections::{BTreeMap, HashMap};
use std::io::{Read, Write};
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex, mpsc};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use fastmcp_rust::server::{BoxFuture, FinalMethodOutcome};
use fastmcp_rust::{
AccessToken, AuthContext, AuthRequest, CacheScope, CacheTtl, CanonicalHttpUrl,
ClientCapabilities, ClientHttpConnectionError, ClientHttpResponse, ClientProtocolPlan,
CompleteResult, CompletionHandler, Content, ContentBlock, CoreResult, Cx, DuplicateBehavior,
EmbeddedResourceContents, FinalCallToolResult, FinalCoreResult, FinalElicitationContextExt,
FinalEmbeddedRootsListParams, FinalGetPromptResult, FinalPromptMessage,
FinalReadResourceResult, FinalResourceTemplate, FinalRootsContextExt, FinalSamplingContextExt,
FinalToolOutcome, HttpNonquiescentShutdown, HttpServerShutdown, HttpShutdownSettlement,
JsonRpcMessage, JsonRpcRequest, ListPromptsParams, ListResourceTemplatesParams,
ListResourcesParams, ListToolsParams, McpContext, McpError, McpErrorCode, McpOutcome,
McpRequestCancellation, McpResult, Middleware, MiddlewareDecision, ModernHttpResponseKind,
ModernHttpResponseStream, Outcome, Prompt, PromptArgument, PromptHandler, PromptMessage,
ProtocolEra, ProtocolPolicy, RawIcon, Resource, ResourceContent, ResourceHandler,
ResourceTemplate, ResultMeta, Role, SseLimits, StaticTokenVerifier, TokenAuthProvider,
TokenVerifier, Tool, ToolAnnotations, ToolErrorKind, ToolHandler, auto, caching, core,
legacy_2024, modern, prompt, providers, rate_limiting, resource, tool, transform,
};
#[cfg(feature = "tasks")]
use fastmcp_rust::{
ApplicationTaskSupervisor, FinalTask, FinalTaskId, FinalTaskInputRequests,
FinalTaskInputResponses, FinalTaskRuntime, FinalTaskRuntimeConfig, FinalTaskSupervisorFuture,
FinalTaskSupervisorHandoff, FinalTaskWatchEvent, FinalTaskWorkDescriptor, FinalToolCallOutcome,
RequestId,
};
#[cfg(feature = "websocket-experimental")]
use fastmcp_rust::{
AsyncWsClientTransport, WebSocketNonquiescentShutdown, WebSocketServerShutdown,
};
#[cfg(feature = "proxy")]
use fastmcp_rust::{
ClientInfo, ProxyClient, ProxyPromptCatalog, ProxyResourceCatalog, ProxyToolCatalog,
StdioSubscriptionEvent,
};
use fastmcp_server::ServerBuilder;
use serde_json::json;
const PUBLIC_HTTP_TOOL_NAME: &str = "public-http-e2e-tool";
const PUBLIC_HTTP_TASK_TOOL_NAME: &str = "public-http-e2e-task";
const PUBLIC_HTTP_LOG_TOOL_NAME: &str = "public-http-e2e-log";
const PUBLIC_HTTP_HANDLER_LOG_TEXT: &str = "public-http-handler-info";
const PUBLIC_HTTP_CURSOR_SECONDARY_TOOL_NAME: &str = "public-http-e2e-cursor-secondary";
const PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME: &str = "public-http-e2e-cursor-other";
const PUBLIC_HTTP_RESOURCE_URI: &str = "test://public-http-e2e/resource";
const PUBLIC_HTTP_PROMPT_NAME: &str = "public-http-e2e-prompt";
const PUBLIC_HTTP_TOOL_ARGUMENT: &str = "cross-era";
const PUBLIC_HTTP_TOOL_TEXT: &str = "tool:cross-era";
const PUBLIC_HTTP_RESOURCE_TEXT: &str = "resource:deterministic";
const PUBLIC_HTTP_PROMPT_TEXT: &str = "prompt:cross-era";
const PUBLIC_HTTP_COMPLETION_VALUE: &str = "cross-era-completion";
const PUBLIC_HTTP_AUTH_TOOL_NAME: &str = "public-http-e2e-auth";
const PUBLIC_HTTP_AUTH_SUBJECT: &str = "e2e-native-http-principal";
const PUBLIC_HTTP_ICON_TOOL_NAME: &str = "public-http-e2e-icon";
const PUBLIC_HTTP_PLAIN_TOOL_NAME: &str = "public-http-e2e-plain";
const PUBLIC_HTTP_ICON_RESOURCE_URI: &str = "test://public-http-e2e/icon-resource";
const PUBLIC_HTTP_PLAIN_RESOURCE_URI: &str = "test://public-http-e2e/plain-resource";
const PUBLIC_HTTP_ICON_PROMPT_NAME: &str = "public-http-e2e-icon-prompt";
const PUBLIC_HTTP_PLAIN_PROMPT_NAME: &str = "public-http-e2e-plain-prompt";
const PUBLIC_HTTP_ICON_SRC: &str = "https://example.test/e2e-icon.png";
const PUBLIC_HTTP_OUTPUT_TOOL_NAME: &str = "public-http-e2e-output";
const PUBLIC_HTTP_COMPOSE_TOOL_NAME: &str = "public-http-e2e-compose";
const PUBLIC_HTTP_COMPOSE_PROMPT_NAME: &str = "public-http-e2e-compose-prompt";
const PUBLIC_HTTP_MACRO_COMPOSE_PROMPT_NAME: &str = "public-http-e2e-macro-compose";
const PUBLIC_HTTP_MACRO_COMPOSE_TOOL_NAME: &str = "public-http-e2e-macro-compose-tool";
const PUBLIC_HTTP_MACRO_COMPOSE_RESOURCE_URI: &str = "test://public-http-e2e/macro-compose";
const PUBLIC_HTTP_MACRO_COMPOSE_MISSING_TOOL_RESOURCE_URI: &str =
"test://public-http-e2e/macro-compose-missing-tool";
const PUBLIC_HTTP_COMPOSE_RESOURCE_URI: &str = "test://public-http-e2e/compose";
const PUBLIC_HTTP_COMPOSE_MISSING_TOOL_RESOURCE_URI: &str =
"test://public-http-e2e/compose-missing-tool";
const PUBLIC_HTTP_COMPOSE_MISSING_RESOURCE_URI: &str =
"test://public-http-e2e/compose-missing-resource";
const PUBLIC_HTTP_STATE_TOOL_NAME: &str = "public-http-e2e-state";
const PUBLIC_HTTP_STATE_KEY: &str = "e2e-request-local-state";
const PUBLIC_HTTP_CAPS_TOOL_NAME: &str = "public-http-e2e-caps";
const PUBLIC_HTTP_INSTRUCTIONS: &str = "use the public-http-e2e-tool";
const PUBLIC_HTTP_DEFAULT_TOOL_NAME: &str = "public-http-e2e-default";
const PUBLIC_HTTP_DEFAULT_SUFFIX: &str = "!";
const PUBLIC_HTTP_RICH_TOOL_NAME: &str = "public-http-e2e-rich";
const PUBLIC_HTTP_IMAGE_MIME: &str = "image/png";
const PUBLIC_HTTP_AUDIO_MIME: &str = "audio/wav";
const PUBLIC_HTTP_IMAGE_DATA: &str = "e2eimage";
const PUBLIC_HTTP_AUDIO_DATA: &str = "e2eaudio";
const PUBLIC_HTTP_CUSTOM_AUTH_SUBJECT: &str = "e2e-custom-verifier-principal";
const PUBLIC_HTTP_CUSTOM_AUTH_TOKEN: &str = "gamma";
/// The deterministic user handler exercised through both public HTTP facades.
#[tool(name = "public-http-e2e-tool", tags = ["cursor"])]
fn public_http_value(_ctx: &McpContext, value: String) -> String {
format!("tool:{value}")
}
/// Additional catalog entries make the public cursor test cross a real page
/// boundary while keeping the ordinary tool handler deterministic.
#[tool(name = "public-http-e2e-cursor-secondary", tags = ["cursor"])]
fn public_http_cursor_secondary(_ctx: &McpContext) -> String {
"cursor-secondary".to_owned()
}
/// Deliberately outside the cursor query used by the positive continuation.
#[tool(name = "public-http-e2e-cursor-other", tags = ["other"])]
fn public_http_cursor_other(_ctx: &McpContext) -> String {
"cursor-other".to_owned()
}
/// The deterministic resource exercised through both public HTTP facades.
#[resource(uri = "test://public-http-e2e/resource")]
fn public_http_snapshot(_ctx: &McpContext) -> String {
PUBLIC_HTTP_RESOURCE_TEXT.to_owned()
}
#[resource(uri = "test://public-http-e2e/cursor-a", tags = ["cursor"])]
fn public_http_cursor_resource_a(_ctx: &McpContext) -> String {
"cursor-resource-a".to_owned()
}
#[resource(uri = "test://public-http-e2e/cursor-b", tags = ["cursor"])]
fn public_http_cursor_resource_b(_ctx: &McpContext) -> String {
"cursor-resource-b".to_owned()
}
#[prompt(name = "public-http-e2e-cursor-prompt-a", tags = ["cursor"])]
fn public_http_cursor_prompt_a(_ctx: &McpContext) -> Vec<PromptMessage> {
vec![PromptMessage {
role: Role::User,
content: Content::text("cursor-prompt-a"),
}]
}
#[prompt(name = "public-http-e2e-cursor-prompt-b", tags = ["cursor"])]
fn public_http_cursor_prompt_b(_ctx: &McpContext) -> Vec<PromptMessage> {
vec![PromptMessage {
role: Role::User,
content: Content::text("cursor-prompt-b"),
}]
}
#[resource(uri = "test://public-http-e2e/tmpl-cursor/{id}", tags = ["cursor"])]
fn public_http_cursor_template(_ctx: &McpContext, id: String) -> String {
format!("cursor-tmpl:{id}")
}
#[resource(uri = "test://public-http-e2e/tmpl-other/{id}", tags = ["other"])]
fn public_http_other_template(_ctx: &McpContext, id: String) -> String {
format!("other-tmpl:{id}")
}
/// The deterministic prompt exercised through both public HTTP facades.
#[prompt(name = "public-http-e2e-prompt")]
fn public_http_instruction(_ctx: &McpContext, subject: String) -> Vec<PromptMessage> {
vec![PromptMessage {
role: Role::User,
content: Content::Text {
text: format!("prompt:{subject}"),
},
}]
}
const PUBLIC_HTTP_PROMPT_COMPOSE_TOOL_NAME: &str = "public-http-e2e-prompt-compose";
/// Composes the public HTTP e2e prompt through `ctx.get_prompt`.
#[tool(
name = "public-http-e2e-prompt-compose",
defaults(prompt = "public-http-e2e-prompt")
)]
async fn public_http_prompt_compose(
ctx: &McpContext,
subject: String,
prompt: String,
) -> McpResult<String> {
let text = ctx
.get_prompt_text(&prompt, HashMap::from([("subject".to_owned(), subject)]))
.await
.map_err(|error| {
McpError::invalid_request(format!("compose-nested-prompt:{prompt}:{}", error.message))
})?;
Ok(format!("compose-prompt:{text}"))
}
const PUBLIC_HTTP_PROMPT_COMPOSE_RESOURCE_URI: &str = "test://public-http-e2e/compose-prompt";
const PUBLIC_HTTP_PROMPT_COMPOSE_MISSING_RESOURCE_URI: &str =
"test://public-http-e2e/compose-prompt-missing";
const PUBLIC_HTTP_FROM_PROMPT_NAME: &str = "public-http-e2e-from-prompt";
async fn compose_nested_http_prompt(
ctx: &McpContext,
subject: &str,
prompt: &str,
) -> McpResult<String> {
let text = ctx
.get_prompt_text(
prompt,
HashMap::from([("subject".to_owned(), subject.to_owned())]),
)
.await
.map_err(|error| {
McpError::invalid_request(format!("compose-nested-prompt:{prompt}:{}", error.message))
})?;
Ok(format!("compose-prompt:{text}"))
}
/// Composes the public HTTP e2e prompt through `ctx.get_prompt` from a resource.
#[resource(uri = "test://public-http-e2e/compose-prompt")]
async fn public_http_resource_composes_prompt(ctx: &McpContext) -> McpResult<String> {
compose_nested_http_prompt(ctx, "alpha", PUBLIC_HTTP_PROMPT_NAME).await
}
/// Near-identical resource whose only change is the nested prompt name.
#[resource(uri = "test://public-http-e2e/compose-prompt-missing")]
async fn public_http_resource_composes_missing_prompt(ctx: &McpContext) -> McpResult<String> {
compose_nested_http_prompt(ctx, "alpha", "public-http-e2e-missing").await
}
/// Composes the public HTTP e2e prompt through `ctx.get_prompt` from a prompt.
#[prompt(
name = "public-http-e2e-from-prompt",
defaults(prompt = "public-http-e2e-prompt")
)]
async fn public_http_from_prompt(
ctx: &McpContext,
subject: String,
prompt: String,
) -> McpResult<Vec<PromptMessage>> {
let composed = compose_nested_http_prompt(ctx, &subject, &prompt).await?;
Ok(vec![PromptMessage {
role: Role::User,
content: Content::text(composed),
}])
}
/// Generated async `#[prompt]` that composes nested tool and resource reads.
#[prompt(name = "public-http-e2e-macro-compose")]
async fn public_http_macro_compose(
ctx: &McpContext,
value: String,
tool: String,
resource: String,
) -> McpResult<Vec<PromptMessage>> {
if !ctx.can_call_tools() || !ctx.can_read_resources() {
return Err(McpError::invalid_request(format!(
"compose-no-router: can_call_tools={} can_read_resources={}",
ctx.can_call_tools(),
ctx.can_read_resources()
)));
}
let tool_text = ctx
.call_tool_text(&tool, json!({ "value": value }))
.await
.map_err(|error| {
McpError::invalid_request(format!(
"compose-nested-tool:{tool}:{:?}:{}",
error.code, error.message
))
})?;
let resource_text = ctx.read_resource_text(&resource).await.map_err(|error| {
McpError::invalid_request(format!(
"compose-nested-resource:{resource}:{:?}:{}",
error.code, error.message
))
})?;
Ok(vec![PromptMessage {
role: Role::User,
content: Content::text(format!("compose:{tool_text}|{resource_text}")),
}])
}
async fn public_http_compose_nested(
ctx: &McpContext,
value: &str,
tool: &str,
resource: &str,
) -> McpResult<String> {
if !ctx.can_call_tools() || !ctx.can_read_resources() {
return Err(McpError::invalid_request(format!(
"compose-no-router: can_call_tools={} can_read_resources={}",
ctx.can_call_tools(),
ctx.can_read_resources()
)));
}
let tool_text = ctx
.call_tool_text(tool, json!({ "value": value }))
.await
.map_err(|error| {
McpError::invalid_request(format!(
"compose-nested-tool:{tool}:{:?}:{}",
error.code, error.message
))
})?;
let resource_text = ctx.read_resource_text(resource).await.map_err(|error| {
McpError::invalid_request(format!(
"compose-nested-resource:{resource}:{:?}:{}",
error.code, error.message
))
})?;
Ok(format!("compose:{tool_text}|{resource_text}"))
}
/// Generated async `#[tool]` that composes nested tool and resource reads.
#[tool(name = "public-http-e2e-macro-compose-tool")]
async fn public_http_macro_compose_tool(
ctx: &McpContext,
value: String,
tool: String,
resource: String,
) -> McpResult<String> {
public_http_compose_nested(ctx, &value, &tool, &resource).await
}
/// Generated async `#[resource]` that composes nested tool and resource reads.
#[resource(uri = "test://public-http-e2e/macro-compose")]
async fn public_http_macro_compose_view(ctx: &McpContext) -> McpResult<String> {
public_http_compose_nested(
ctx,
"alpha",
PUBLIC_HTTP_TOOL_NAME,
PUBLIC_HTTP_RESOURCE_URI,
)
.await
}
/// Near-identical generated resource whose only change is the nested tool name.
#[resource(uri = "test://public-http-e2e/macro-compose-missing-tool")]
async fn public_http_macro_compose_missing(ctx: &McpContext) -> McpResult<String> {
public_http_compose_nested(
ctx,
"alpha",
"public-http-e2e-missing",
PUBLIC_HTTP_RESOURCE_URI,
)
.await
}
/// Live modern HTTP tool whose omitted `suffix` is injected from `#[tool]` defaults.
#[tool(name = "public-http-e2e-default", defaults(suffix = "!"))]
fn public_http_default(_ctx: &McpContext, name: String, suffix: String) -> String {
format!("greet:{name}{suffix}")
}
/// Live modern HTTP tool whose official Tasks create is visible through a prefixed gateway.
#[cfg(all(feature = "proxy", feature = "tasks"))]
struct PublicHttpTaskTool;
#[cfg(all(feature = "proxy", feature = "tasks"))]
impl ToolHandler for PublicHttpTaskTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_TASK_TOOL_NAME.to_owned(),
description: Some("Creates one official final Tasks operation".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, _ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
Ok(vec![Content::text("exact-2024 Tasks are unavailable")])
}
fn declares_final_tasks(&self) -> bool {
true
}
fn call_final_outcome(
&self,
_ctx: &McpContext,
_arguments: serde_json::Value,
) -> McpResult<FinalToolOutcome> {
Ok(FinalToolOutcome::CreateTask {
work_descriptor: FinalTaskWorkDescriptor::new(json!({
"operation": "public-http-e2e-task",
}))?,
status_message: Some("working through the public HTTP as_proxy Tasks relay".to_owned()),
})
}
}
/// Keeps one created official Task live until `tasks/cancel` is honored.
#[cfg(all(feature = "proxy", feature = "tasks"))]
struct PublicHttpHoldingTaskSupervisor;
#[cfg(all(feature = "proxy", feature = "tasks"))]
impl ApplicationTaskSupervisor for PublicHttpHoldingTaskSupervisor {
fn resume<'a>(
&'a self,
cx: &'a Cx,
handoff: FinalTaskSupervisorHandoff,
) -> FinalTaskSupervisorFuture<'a> {
Box::pin(async move {
match handoff {
FinalTaskSupervisorHandoff::Initial(initial) => {
let requests: FinalTaskInputRequests = serde_json::from_value(json!({
"roots": {"method": "roots/list"}
}))
.map_err(|error| {
McpError::internal_error(format!(
"public HTTP as_proxy Tasks input descriptor is invalid: {error}"
))
})?;
initial.require_input(
requests,
Some(
"awaiting roots through the public HTTP as_proxy Tasks relay"
.to_owned(),
),
)?;
}
FinalTaskSupervisorHandoff::Resumed(accepted) => loop {
if accepted.is_cancellation_requested()? {
accepted.honor_cancellation(Some(
"cancelled through the public HTTP as_proxy Tasks relay".to_owned(),
))?;
break;
}
cx.checkpoint()
.map_err(|error| McpError::internal_error(error.to_string()))?;
asupersync::time::sleep(cx.now(), Duration::from_millis(1)).await;
},
}
Ok(())
})
}
}
#[derive(Default)]
struct PublicHttpHandlerCallCounters {
tool: AtomicUsize,
resource: AtomicUsize,
prompt: AtomicUsize,
completion: AtomicUsize,
}
#[derive(Debug, Eq, PartialEq)]
struct PublicHttpHandlerCallSnapshot {
tool: usize,
resource: usize,
prompt: usize,
completion: usize,
}
impl PublicHttpHandlerCallCounters {
fn snapshot(&self) -> PublicHttpHandlerCallSnapshot {
PublicHttpHandlerCallSnapshot {
tool: self.tool.load(Ordering::SeqCst),
resource: self.resource.load(Ordering::SeqCst),
prompt: self.prompt.load(Ordering::SeqCst),
completion: self.completion.load(Ordering::SeqCst),
}
}
}
struct CountingPublicHttpValue {
counters: Arc<PublicHttpHandlerCallCounters>,
}
impl ToolHandler for CountingPublicHttpValue {
fn definition(&self) -> fastmcp_rust::Tool {
PublicHttpValue.definition()
}
fn call(&self, context: &McpContext, arguments: serde_json::Value) -> McpResult<Vec<Content>> {
self.counters.tool.fetch_add(1, Ordering::SeqCst);
PublicHttpValue.call(context, arguments)
}
}
/// Second registration of the same catalog name, used to prove on_duplicate.
struct ReplacedPublicHttpValue;
impl ToolHandler for ReplacedPublicHttpValue {
fn definition(&self) -> Tool {
PublicHttpValue.definition()
}
fn call(&self, _context: &McpContext, arguments: serde_json::Value) -> McpResult<Vec<Content>> {
let value = arguments
.get("value")
.and_then(serde_json::Value::as_str)
.unwrap_or("missing");
Ok(vec![Content::text(format!("replaced:{value}"))])
}
}
/// Local gateway tool registered under a prefixed as_proxy name so
/// `on_duplicate` can keep or replace the proxied catalog entry.
struct LocalPrefixedPublicHttpValue {
name: String,
}
impl ToolHandler for LocalPrefixedPublicHttpValue {
fn definition(&self) -> Tool {
let mut definition = PublicHttpValue.definition();
definition.name = self.name.clone();
definition
}
fn call(&self, _context: &McpContext, arguments: serde_json::Value) -> McpResult<Vec<Content>> {
let value = arguments
.get("value")
.and_then(serde_json::Value::as_str)
.unwrap_or("missing");
Ok(vec![Content::text(format!("local:{value}"))])
}
}
/// Local gateway tool registered as `ext/echo` so stdio as_proxy
/// `on_duplicate` can keep or replace the prefixed echo catalog entry.
struct LocalPrefixedEcho;
impl ToolHandler for LocalPrefixedEcho {
fn definition(&self) -> Tool {
Tool {
name: "ext/echo".to_owned(),
description: Some(
"Local colliding echo used to prove as_proxy on_duplicate".to_owned(),
),
input_schema: json!({
"type": "object",
"properties": {
"message": {"type": "string"}
},
"required": ["message"]
}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, _context: &McpContext, arguments: serde_json::Value) -> McpResult<Vec<Content>> {
let message = arguments
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("missing");
Ok(vec![Content::text(format!("local:{message}"))])
}
}
/// Returns the committed native-HTTP `AuthContext` subject.
struct PublicHttpAuthSubject {
counters: Arc<PublicHttpHandlerCallCounters>,
}
impl ToolHandler for PublicHttpAuthSubject {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_AUTH_TOOL_NAME.to_owned(),
description: Some("Returns the committed native HTTP auth subject".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, context: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
self.counters.tool.fetch_add(1, Ordering::SeqCst);
let subject = context
.auth()
.and_then(|auth| auth.subject)
.unwrap_or_else(|| "anonymous".to_owned());
Ok(vec![Content::text(format!("subject:{subject}"))])
}
}
/// Custom `TokenVerifier` that is not a `StaticTokenVerifier`.
struct PublicHttpCustomVerifier;
impl TokenVerifier for PublicHttpCustomVerifier {
fn verify(
&self,
_ctx: &McpContext,
_request: AuthRequest<'_>,
token: &AccessToken,
) -> McpResult<AuthContext> {
if token.scheme.eq_ignore_ascii_case("Bearer")
&& token.token == PUBLIC_HTTP_CUSTOM_AUTH_TOKEN
{
return Ok(AuthContext::with_subject(PUBLIC_HTTP_CUSTOM_AUTH_SUBJECT));
}
Err(McpError::invalid_request("unknown custom token"))
}
}
/// Returns image and audio content blocks on a live modern tools/call.
struct PublicHttpRichTool;
impl ToolHandler for PublicHttpRichTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_RICH_TOOL_NAME.to_owned(),
description: Some("Returns image and audio content blocks".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(
&self,
_context: &McpContext,
_arguments: serde_json::Value,
) -> McpResult<Vec<Content>> {
// Exact 2024-11-05 has image but not audio. Keep the representable
// block here; the final hook below authors both.
Ok(vec![Content::image_base64(
PUBLIC_HTTP_IMAGE_DATA,
PUBLIC_HTTP_IMAGE_MIME,
)])
}
fn call_final(
&self,
_context: &McpContext,
_arguments: serde_json::Value,
) -> McpResult<CompleteResult<FinalCallToolResult>> {
Ok(CompleteResult::new(
FinalCallToolResult {
content: vec![
ContentBlock::Image {
data: PUBLIC_HTTP_IMAGE_DATA.to_owned(),
mime_type: PUBLIC_HTTP_IMAGE_MIME.to_owned(),
annotations: None,
meta: None,
additional: BTreeMap::new(),
},
ContentBlock::Audio {
data: PUBLIC_HTTP_AUDIO_DATA.to_owned(),
mime_type: PUBLIC_HTTP_AUDIO_MIME.to_owned(),
annotations: None,
meta: None,
additional: BTreeMap::new(),
},
],
is_error: false,
structured_content: None,
},
ResultMeta::empty(),
))
}
}
/// Advertises exact-final catalog icons, title, and annotations.
struct PublicHttpIconTool {
icons: Vec<RawIcon>,
}
impl ToolHandler for PublicHttpIconTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_ICON_TOOL_NAME.to_owned(),
description: Some("Catalog metadata tool with final icons".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: Some("1.2.3".to_owned()),
tags: vec!["catalog".to_owned()],
annotations: Some(ToolAnnotations::new().read_only(true).idempotent(true)),
}
}
fn final_title(&self) -> Option<&str> {
Some("Icon Tool")
}
fn final_icons(&self) -> Option<&[RawIcon]> {
Some(&self.icons)
}
fn call(
&self,
_context: &McpContext,
_arguments: serde_json::Value,
) -> McpResult<Vec<Content>> {
Ok(vec![Content::text("icon")])
}
}
/// Near-identical peer that advertises no final icons, title, or annotations.
struct PublicHttpPlainTool;
impl ToolHandler for PublicHttpPlainTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_PLAIN_TOOL_NAME.to_owned(),
description: Some("Catalog metadata peer without final icons".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(
&self,
_context: &McpContext,
_arguments: serde_json::Value,
) -> McpResult<Vec<Content>> {
Ok(vec![Content::text("plain")])
}
}
/// Advertises exact-final resource catalog icons and title.
struct PublicHttpIconResource {
icons: Vec<RawIcon>,
}
impl ResourceHandler for PublicHttpIconResource {
fn definition(&self) -> Resource {
Resource {
uri: PUBLIC_HTTP_ICON_RESOURCE_URI.to_owned(),
name: "icon-resource".to_owned(),
description: Some("Catalog metadata resource with final icons".to_owned()),
mime_type: Some("text/plain".to_owned()),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn final_title(&self) -> Option<&str> {
Some("Icon Resource")
}
fn final_icons(&self) -> Option<&[RawIcon]> {
Some(&self.icons)
}
fn read(&self, _context: &McpContext) -> McpResult<Vec<ResourceContent>> {
Ok(vec![ResourceContent {
uri: PUBLIC_HTTP_ICON_RESOURCE_URI.to_owned(),
mime_type: Some("text/plain".to_owned()),
text: Some("icon-resource".to_owned()),
blob: None,
}])
}
}
/// Near-identical peer that advertises no final resource icons or title.
struct PublicHttpPlainResource;
impl ResourceHandler for PublicHttpPlainResource {
fn definition(&self) -> Resource {
Resource {
uri: PUBLIC_HTTP_PLAIN_RESOURCE_URI.to_owned(),
name: "plain-resource".to_owned(),
description: Some("Catalog metadata peer without final resource icons".to_owned()),
mime_type: Some("text/plain".to_owned()),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn read(&self, _context: &McpContext) -> McpResult<Vec<ResourceContent>> {
Ok(vec![ResourceContent {
uri: PUBLIC_HTTP_PLAIN_RESOURCE_URI.to_owned(),
mime_type: Some("text/plain".to_owned()),
text: Some("plain-resource".to_owned()),
blob: None,
}])
}
}
/// Advertises exact-final prompt catalog icons and title.
struct PublicHttpIconPrompt {
icons: Vec<RawIcon>,
}
impl PromptHandler for PublicHttpIconPrompt {
fn definition(&self) -> Prompt {
Prompt {
name: PUBLIC_HTTP_ICON_PROMPT_NAME.to_owned(),
description: Some("Catalog metadata prompt with final icons".to_owned()),
arguments: Vec::new(),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn final_title(&self) -> Option<&str> {
Some("Icon Prompt")
}
fn final_icons(&self) -> Option<&[RawIcon]> {
Some(&self.icons)
}
fn get(
&self,
_ctx: &McpContext,
_arguments: HashMap<String, String>,
) -> McpResult<Vec<PromptMessage>> {
Ok(vec![PromptMessage {
role: Role::User,
content: Content::text("icon-prompt"),
}])
}
}
/// Near-identical peer that advertises no final prompt icons or title.
struct PublicHttpPlainPrompt;
impl PromptHandler for PublicHttpPlainPrompt {
fn definition(&self) -> Prompt {
Prompt {
name: PUBLIC_HTTP_PLAIN_PROMPT_NAME.to_owned(),
description: Some("Catalog metadata peer without final prompt icons".to_owned()),
arguments: Vec::new(),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn get(
&self,
_ctx: &McpContext,
_arguments: HashMap<String, String>,
) -> McpResult<Vec<PromptMessage>> {
Ok(vec![PromptMessage {
role: Role::User,
content: Content::text("plain-prompt"),
}])
}
}
/// Advertises an output schema and authors matching structured content.
struct PublicHttpOutputTool;
impl ToolHandler for PublicHttpOutputTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_OUTPUT_TOOL_NAME.to_owned(),
description: Some(
"Returns structured output matching the advertised schema".to_owned(),
),
input_schema: json!({
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"]
}),
output_schema: Some(json!({
"type": "object",
"properties": {"value": {"type": "string"}},
"required": ["value"]
})),
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn final_tool_error_structured_content(
&self,
kind: ToolErrorKind,
) -> Option<serde_json::Value> {
Some(json!({
"value": match kind {
ToolErrorKind::InputValidation => "input-error",
ToolErrorKind::Handler => "handler-error",
}
}))
}
fn call(&self, _context: &McpContext, arguments: serde_json::Value) -> McpResult<Vec<Content>> {
let value = arguments
.get("value")
.and_then(serde_json::Value::as_str)
.unwrap_or("missing");
Ok(vec![Content::text(format!("tool:{value}"))])
}
fn call_final(
&self,
_context: &McpContext,
arguments: serde_json::Value,
) -> McpResult<CompleteResult<FinalCallToolResult>> {
let value = arguments
.get("value")
.and_then(serde_json::Value::as_str)
.unwrap_or("missing")
.to_owned();
Ok(CompleteResult::new(
FinalCallToolResult {
content: vec![ContentBlock::text(format!("tool:{value}"))],
is_error: false,
structured_content: Some(json!({"value": value})),
},
ResultMeta::empty(),
))
}
}
/// Nested `ctx.call_tool` + `ctx.read_resource` through the request-owned router.
struct PublicHttpComposeTool;
impl ToolHandler for PublicHttpComposeTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_COMPOSE_TOOL_NAME.to_owned(),
description: Some(
"Composes a nested tools/call and resources/read on the same request".to_owned(),
),
input_schema: json!({
"type": "object",
"properties": {
"value": {"type": "string"},
"tool": {"type": "string"},
"resource": {"type": "string"}
},
"required": ["value"]
}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(
&self,
_context: &McpContext,
_arguments: serde_json::Value,
) -> McpResult<Vec<Content>> {
// InvalidRequest survives sanitize_handler_error; InternalError is
// always rewritten to the opaque "Internal server error" terminal.
Err(McpError::invalid_request(
"compose-sync-hook: compose must run through the request-owned final async hook",
))
}
fn call_async_in_request<'a>(
&'a self,
ctx: &'a McpContext,
request_cx: &'a Cx,
arguments: serde_json::Value,
) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
Box::pin(async move {
match self.compose_text(ctx, request_cx, arguments).await {
Outcome::Ok(text) => Outcome::Ok(vec![Content::text(text)]),
Outcome::Err(error) => Outcome::Err(error),
Outcome::Cancelled(cancelled) => Outcome::Cancelled(cancelled),
Outcome::Panicked(panic) => Outcome::Panicked(panic),
}
})
}
fn call_final_outcome_async_in_request<'a>(
&'a self,
ctx: &'a McpContext,
request_cx: &'a Cx,
arguments: serde_json::Value,
) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
self.compose_from_request(ctx, request_cx, arguments)
}
fn call_final_outcome_async_resuming_in_request<'a>(
&'a self,
ctx: &'a McpContext,
request_cx: &'a Cx,
arguments: serde_json::Value,
_resume_inputs: Option<&'a fastmcp_rust::MrtrCompletedInputs>,
) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
self.compose_from_request(ctx, request_cx, arguments)
}
}
impl PublicHttpComposeTool {
fn compose_text<'a>(
&'a self,
ctx: &'a McpContext,
_request_cx: &'a Cx,
arguments: serde_json::Value,
) -> BoxFuture<'a, McpOutcome<String>> {
Box::pin(async move {
if !ctx.can_call_tools() || !ctx.can_read_resources() {
return Outcome::Err(McpError::invalid_request(format!(
"compose-no-router: can_call_tools={} can_read_resources={}",
ctx.can_call_tools(),
ctx.can_read_resources()
)));
}
let value = arguments
.get("value")
.and_then(serde_json::Value::as_str)
.unwrap_or("missing");
let tool_name = arguments
.get("tool")
.and_then(serde_json::Value::as_str)
.unwrap_or(PUBLIC_HTTP_TOOL_NAME);
let resource_uri = arguments
.get("resource")
.and_then(serde_json::Value::as_str)
.unwrap_or(PUBLIC_HTTP_RESOURCE_URI);
let tool_text = match ctx
.call_tool_text(tool_name, json!({ "value": value }))
.await
{
Ok(text) => text,
Err(error) => {
return Outcome::Err(McpError::invalid_request(format!(
"compose-nested-tool:{tool_name}:{:?}:{}",
error.code, error.message
)));
}
};
let resource_text = match ctx.read_resource_text(resource_uri).await {
Ok(text) => text,
Err(error) => {
return Outcome::Err(McpError::invalid_request(format!(
"compose-nested-resource:{resource_uri}:{:?}:{}",
error.code, error.message
)));
}
};
Outcome::Ok(format!("compose:{tool_text}|{resource_text}"))
})
}
fn compose_from_request<'a>(
&'a self,
ctx: &'a McpContext,
request_cx: &'a Cx,
arguments: serde_json::Value,
) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
Box::pin(async move {
match self.compose_text(ctx, request_cx, arguments).await {
Outcome::Ok(text) => Outcome::Ok(FinalToolOutcome::Complete(CompleteResult::new(
FinalCallToolResult {
content: vec![ContentBlock::text(text)],
is_error: false,
structured_content: None,
},
ResultMeta::empty(),
))),
Outcome::Err(error) => Outcome::Err(error),
Outcome::Cancelled(cancelled) => Outcome::Cancelled(cancelled),
Outcome::Panicked(panic) => Outcome::Panicked(panic),
}
})
}
}
/// Nested `ctx.call_tool` + `ctx.read_resource` from a prompt handler.
struct PublicHttpComposePrompt;
impl PromptHandler for PublicHttpComposePrompt {
fn definition(&self) -> Prompt {
Prompt {
name: PUBLIC_HTTP_COMPOSE_PROMPT_NAME.to_owned(),
description: Some(
"Composes a nested tools/call and resources/read from prompts/get".to_owned(),
),
arguments: vec![
PromptArgument {
name: "value".to_owned(),
description: Some("Value forwarded to the nested tool".to_owned()),
required: true,
},
PromptArgument {
name: "tool".to_owned(),
description: Some("Nested tool name".to_owned()),
required: false,
},
PromptArgument {
name: "resource".to_owned(),
description: Some("Nested resource URI".to_owned()),
required: false,
},
],
icon: None,
version: None,
tags: Vec::new(),
}
}
fn get(
&self,
_ctx: &McpContext,
_arguments: HashMap<String, String>,
) -> McpResult<Vec<PromptMessage>> {
Err(McpError::invalid_request(
"compose-sync-hook: prompt compose must run through the request-owned async hook",
))
}
fn get_async_in_request<'a>(
&'a self,
ctx: &'a McpContext,
request_cx: &'a Cx,
arguments: HashMap<String, String>,
) -> BoxFuture<'a, McpOutcome<Vec<PromptMessage>>> {
Box::pin(async move {
match self.compose_text(ctx, request_cx, arguments).await {
Outcome::Ok(text) => Outcome::Ok(vec![PromptMessage {
role: Role::User,
content: Content::text(text),
}]),
Outcome::Err(error) => Outcome::Err(error),
Outcome::Cancelled(cancelled) => Outcome::Cancelled(cancelled),
Outcome::Panicked(panic) => Outcome::Panicked(panic),
}
})
}
fn get_final_outcome_async_in_request<'a>(
&'a self,
ctx: &'a McpContext,
request_cx: &'a Cx,
arguments: HashMap<String, String>,
) -> BoxFuture<'a, McpOutcome<FinalMethodOutcome<FinalGetPromptResult>>> {
self.compose_from_request(ctx, request_cx, arguments)
}
fn get_final_outcome_async_resuming_in_request<'a>(
&'a self,
ctx: &'a McpContext,
request_cx: &'a Cx,
arguments: HashMap<String, String>,
_resume_inputs: Option<&'a fastmcp_rust::MrtrCompletedInputs>,
) -> BoxFuture<'a, McpOutcome<FinalMethodOutcome<FinalGetPromptResult>>> {
self.compose_from_request(ctx, request_cx, arguments)
}
}
impl PublicHttpComposePrompt {
fn compose_text<'a>(
&'a self,
ctx: &'a McpContext,
_request_cx: &'a Cx,
arguments: HashMap<String, String>,
) -> BoxFuture<'a, McpOutcome<String>> {
Box::pin(async move {
if !ctx.can_call_tools() || !ctx.can_read_resources() {
return Outcome::Err(McpError::invalid_request(format!(
"compose-no-router: can_call_tools={} can_read_resources={}",
ctx.can_call_tools(),
ctx.can_read_resources()
)));
}
let value = arguments
.get("value")
.map(String::as_str)
.unwrap_or("missing");
let tool_name = arguments
.get("tool")
.map(String::as_str)
.unwrap_or(PUBLIC_HTTP_TOOL_NAME);
let resource_uri = arguments
.get("resource")
.map(String::as_str)
.unwrap_or(PUBLIC_HTTP_RESOURCE_URI);
let tool_text = match ctx
.call_tool_text(tool_name, json!({ "value": value }))
.await
{
Ok(text) => text,
Err(error) => {
return Outcome::Err(McpError::invalid_request(format!(
"compose-nested-tool:{tool_name}:{:?}:{}",
error.code, error.message
)));
}
};
let resource_text = match ctx.read_resource_text(resource_uri).await {
Ok(text) => text,
Err(error) => {
return Outcome::Err(McpError::invalid_request(format!(
"compose-nested-resource:{resource_uri}:{:?}:{}",
error.code, error.message
)));
}
};
Outcome::Ok(format!("compose:{tool_text}|{resource_text}"))
})
}
fn compose_from_request<'a>(
&'a self,
ctx: &'a McpContext,
request_cx: &'a Cx,
arguments: HashMap<String, String>,
) -> BoxFuture<'a, McpOutcome<FinalMethodOutcome<FinalGetPromptResult>>> {
Box::pin(async move {
match self.compose_text(ctx, request_cx, arguments).await {
Outcome::Ok(text) => {
Outcome::Ok(FinalMethodOutcome::Complete(CompleteResult::new(
FinalGetPromptResult {
description: None,
messages: vec![FinalPromptMessage {
role: Role::User,
content: ContentBlock::text(text),
}],
},
ResultMeta::empty(),
)))
}
Outcome::Err(error) => Outcome::Err(error),
Outcome::Cancelled(cancelled) => Outcome::Cancelled(cancelled),
Outcome::Panicked(panic) => Outcome::Panicked(panic),
}
})
}
}
/// Nested `ctx.call_tool` + `ctx.read_resource` from a resource handler.
struct PublicHttpComposeResource {
uri: &'static str,
tool: &'static str,
resource: &'static str,
}
impl ResourceHandler for PublicHttpComposeResource {
fn definition(&self) -> Resource {
Resource {
uri: self.uri.to_owned(),
name: self.uri.to_owned(),
description: Some(
"Composes a nested tools/call and resources/read from resources/read".to_owned(),
),
mime_type: Some("text/plain".to_owned()),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
Err(McpError::invalid_request(
"compose-sync-hook: resource compose must run through the request-owned async hook",
))
}
fn read_async_with_uri_in_request<'a>(
&'a self,
ctx: &'a McpContext,
request_cx: &'a Cx,
uri: &'a str,
_params: &'a HashMap<String, String>,
) -> BoxFuture<'a, McpOutcome<Vec<ResourceContent>>> {
Box::pin(async move {
match self.compose_text(ctx, request_cx).await {
Outcome::Ok(text) => Outcome::Ok(vec![ResourceContent {
uri: uri.to_owned(),
mime_type: Some("text/plain".to_owned()),
text: Some(text),
blob: None,
}]),
Outcome::Err(error) => Outcome::Err(error),
Outcome::Cancelled(cancelled) => Outcome::Cancelled(cancelled),
Outcome::Panicked(panic) => Outcome::Panicked(panic),
}
})
}
fn read_final_outcome_async_with_uri_in_request<'a>(
&'a self,
ctx: &'a McpContext,
request_cx: &'a Cx,
uri: &'a str,
_params: &'a HashMap<String, String>,
) -> BoxFuture<'a, McpOutcome<FinalMethodOutcome<FinalReadResourceResult>>> {
self.compose_from_request(ctx, request_cx, uri)
}
fn read_final_outcome_async_with_uri_resuming_in_request<'a>(
&'a self,
ctx: &'a McpContext,
request_cx: &'a Cx,
uri: &'a str,
_params: &'a HashMap<String, String>,
_resume_inputs: Option<&'a fastmcp_rust::MrtrCompletedInputs>,
) -> BoxFuture<'a, McpOutcome<FinalMethodOutcome<FinalReadResourceResult>>> {
self.compose_from_request(ctx, request_cx, uri)
}
}
impl PublicHttpComposeResource {
fn compose_text<'a>(
&'a self,
ctx: &'a McpContext,
_request_cx: &'a Cx,
) -> BoxFuture<'a, McpOutcome<String>> {
Box::pin(async move {
if !ctx.can_call_tools() || !ctx.can_read_resources() {
return Outcome::Err(McpError::invalid_request(format!(
"compose-no-router: can_call_tools={} can_read_resources={}",
ctx.can_call_tools(),
ctx.can_read_resources()
)));
}
let tool_text = match ctx
.call_tool_text(self.tool, json!({ "value": "alpha" }))
.await
{
Ok(text) => text,
Err(error) => {
return Outcome::Err(McpError::invalid_request(format!(
"compose-nested-tool:{}:{:?}:{}",
self.tool, error.code, error.message
)));
}
};
let resource_text = match ctx.read_resource_text(self.resource).await {
Ok(text) => text,
Err(error) => {
return Outcome::Err(McpError::invalid_request(format!(
"compose-nested-resource:{}:{:?}:{}",
self.resource, error.code, error.message
)));
}
};
Outcome::Ok(format!("compose:{tool_text}|{resource_text}"))
})
}
fn compose_from_request<'a>(
&'a self,
ctx: &'a McpContext,
request_cx: &'a Cx,
uri: &'a str,
) -> BoxFuture<'a, McpOutcome<FinalMethodOutcome<FinalReadResourceResult>>> {
Box::pin(async move {
match self.compose_text(ctx, request_cx).await {
Outcome::Ok(text) => {
let parsed = match modern::AbsoluteUri::parse(uri) {
Ok(parsed) => parsed,
Err(error) => {
return Outcome::Err(McpError::invalid_request(format!(
"compose-resource-uri:{error}"
)));
}
};
Outcome::Ok(FinalMethodOutcome::Complete(CompleteResult::new(
FinalReadResourceResult {
contents: vec![EmbeddedResourceContents::Text {
uri: parsed,
text,
mime_type: Some("text/plain".to_owned()),
meta: None,
additional: std::collections::BTreeMap::new(),
}],
ttl_ms: CacheTtl::milliseconds(3_600_000),
cache_scope: CacheScope::Private,
},
ResultMeta::empty(),
)))
}
Outcome::Err(error) => Outcome::Err(error),
Outcome::Cancelled(cancelled) => Outcome::Cancelled(cancelled),
Outcome::Panicked(panic) => Outcome::Panicked(panic),
}
})
}
}
/// Session-state write/read/remove on the request-local modern HTTP bag.
struct PublicHttpStateTool;
impl ToolHandler for PublicHttpStateTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_STATE_TOOL_NAME.to_owned(),
description: Some("Reads and writes request-local session state".to_owned()),
input_schema: json!({
"type": "object",
"properties": {
"action": {"type": "string"},
"value": {"type": "string"}
},
"required": ["action"]
}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, context: &McpContext, arguments: serde_json::Value) -> McpResult<Vec<Content>> {
if !context.has_session_state() {
return Err(McpError::internal_error(
"modern HTTP request context must carry session state",
));
}
let action = arguments
.get("action")
.and_then(serde_json::Value::as_str)
.unwrap_or("read");
let rendered = match action {
"write" => {
let value = arguments
.get("value")
.and_then(serde_json::Value::as_str)
.unwrap_or("missing");
if !context.set_state(PUBLIC_HTTP_STATE_KEY, value) {
return Err(McpError::internal_error(
"set_state must succeed on the request-local bag",
));
}
let got = context
.get_state::<String>(PUBLIC_HTTP_STATE_KEY)
.unwrap_or_else(|| "missing".to_owned());
format!("state:{got}")
}
"remove" => {
let _ = context.remove_state(PUBLIC_HTTP_STATE_KEY);
let got = context
.get_state::<String>(PUBLIC_HTTP_STATE_KEY)
.unwrap_or_else(|| "missing".to_owned());
format!("state:{got}")
}
_ => {
let got = context
.get_state::<String>(PUBLIC_HTTP_STATE_KEY)
.unwrap_or_else(|| "missing".to_owned());
format!("state:{got}")
}
};
Ok(vec![Content::text(rendered)])
}
}
/// Reports handler-visible client and server capability flags.
struct PublicHttpCapsTool;
impl ToolHandler for PublicHttpCapsTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_CAPS_TOOL_NAME.to_owned(),
description: Some("Returns handler-visible client and server capabilities".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, context: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
let client = context.client_capabilities();
let server = context.server_capabilities();
Ok(vec![Content::text(format!(
"caps:sampling={};roots={};tools={};resources={}",
client.is_some_and(|caps| caps.sampling),
client.is_some_and(|caps| caps.roots),
server.is_some_and(|caps| caps.tools),
server.is_some_and(|caps| caps.resources),
))])
}
}
struct CountingPublicHttpSnapshot {
counters: Arc<PublicHttpHandlerCallCounters>,
}
impl ResourceHandler for CountingPublicHttpSnapshot {
fn definition(&self) -> fastmcp_rust::Resource {
PublicHttpSnapshotResource.definition()
}
fn read(&self, context: &McpContext) -> McpResult<Vec<fastmcp_rust::ResourceContent>> {
self.counters.resource.fetch_add(1, Ordering::SeqCst);
PublicHttpSnapshotResource.read(context)
}
}
struct CountingPublicHttpInstruction {
counters: Arc<PublicHttpHandlerCallCounters>,
}
impl PromptHandler for CountingPublicHttpInstruction {
fn definition(&self) -> fastmcp_rust::Prompt {
PublicHttpInstructionPrompt.definition()
}
fn get(
&self,
context: &McpContext,
arguments: HashMap<String, String>,
) -> McpResult<Vec<PromptMessage>> {
self.counters.prompt.fetch_add(1, Ordering::SeqCst);
PublicHttpInstructionPrompt.get(context, arguments)
}
}
struct CountingPublicHttpCompletion {
counters: Arc<PublicHttpHandlerCallCounters>,
}
impl CompletionHandler for CountingPublicHttpCompletion {
fn complete_legacy(
&self,
context: &McpContext,
_params: legacy_2024::LegacyCompletionParams,
) -> McpResult<legacy_2024::CompletionValues> {
context.report_progress(0.5, Some("completion-legacy-halfway"));
Ok(legacy_2024::CompletionValues {
values: vec![PUBLIC_HTTP_COMPLETION_VALUE.to_owned()],
total: Some(1),
has_more: Some(false),
})
}
fn complete_final(
&self,
context: &McpContext,
params: modern::FinalCompletionParams,
) -> McpResult<modern::FinalCompletionValues> {
context.report_progress(0.5, Some("completion-halfway"));
self.counters.completion.fetch_add(1, Ordering::SeqCst);
if !matches!(
¶ms.reference,
modern::FinalCompletionReference::PromptWithTitle { name, title }
if name == PUBLIC_HTTP_PROMPT_NAME && title == "Public HTTP E2E Prompt"
) || params.argument.name != "subject"
|| params.argument.value != "cross-era"
|| params
.context
.as_ref()
.and_then(|context| context.arguments.as_ref())
.and_then(|arguments| arguments.get("region"))
.map(String::as_str)
!= Some("us-east-1")
{
return Err(McpError::invalid_params(
"the completion fixture requires the exact modern request shape",
));
}
Ok(modern::FinalCompletionValues {
values: vec![PUBLIC_HTTP_COMPLETION_VALUE.to_owned()],
total: Some(modern::JsonInteger::from(1_i64)),
has_more: Some(false),
})
}
}
fn runtime_block_on<F: std::future::Future>(future: F) -> F::Output {
core::block_on(future)
}
/// Runs one public HTTP operation on the facade-owned runtime. The operation
/// itself is bounded against the supplied caller-owned context clock, so a
/// stalled peer cannot hold this test thread indefinitely.
fn runtime_block_on_bounded<F: std::future::Future>(cx: &Cx, future: F) -> F::Output {
runtime_block_on_bounded_named(cx, "public HTTP operation", future)
}
fn runtime_block_on_bounded_named<F: std::future::Future>(
cx: &Cx,
operation: &str,
future: F,
) -> F::Output {
runtime_block_on(async {
asupersync::time::timeout(cx.now(), HTTP_OPERATION_BOUND, future)
.await
.unwrap_or_else(|_| panic!("{operation} stays within its caller-owned deadline"))
})
}
const HTTP_SERVER_STARTUP_BOUND: Duration = Duration::from_secs(2);
const HTTP_SERVER_TEARDOWN_BOUND: Duration = Duration::from_secs(2);
const HTTP_OPERATION_BOUND: Duration = Duration::from_secs(2);
/// Owns one real public HTTP server composition and proves its teardown.
///
/// The fixture composes one policy-selected server with deterministic public
/// tool, resource, and prompt handlers. Its clients use only shipped facade
/// HTTP clients and real localhost routes; no transcript or mock peer is
/// involved.
struct HttpServerFixture {
address: SocketAddr,
server_cx: Cx,
finished: mpsc::Receiver<Result<HttpServerShutdown, String>>,
shutdown_completion: Option<Result<HttpServerShutdown, String>>,
join: Option<JoinHandle<()>>,
nonquiescent: Option<HttpNonquiescentShutdown>,
handler_calls: Arc<PublicHttpHandlerCallCounters>,
}
/// Retains a just-spawned fixture until the ready handshake succeeds and it
/// can be transferred into `HttpServerFixture`.
struct HttpServerStartupGuard {
server_cx: Option<Cx>,
server_cx_rx: Option<mpsc::Receiver<Cx>>,
finished: Option<mpsc::Receiver<Result<HttpServerShutdown, String>>>,
join: Option<JoinHandle<()>>,
}
impl HttpServerStartupGuard {
fn capture_server_cx(&mut self) {
if self.server_cx.is_some() {
return;
}
let Some(server_cx_rx) = self.server_cx_rx.as_ref() else {
return;
};
if let Ok(server_cx) = server_cx_rx.try_recv() {
self.server_cx = Some(server_cx);
self.server_cx_rx = None;
}
}
fn into_parts(
mut self,
) -> (
Cx,
mpsc::Receiver<Result<HttpServerShutdown, String>>,
Option<JoinHandle<()>>,
) {
(
self.server_cx
.take()
.expect("startup guard retains the runtime-installed server context"),
self.finished
.take()
.expect("startup guard retains completion"),
self.join.take(),
)
}
fn resume_thread_panic_if_finished(&mut self) {
let Some(handle) = self.join.as_ref() else {
return;
};
if !handle.is_finished() {
return;
}
let handle = self
.join
.take()
.expect("finished startup thread retains its join handle");
if let Err(payload) = handle.join() {
std::panic::resume_unwind(payload);
}
}
}
impl Drop for HttpServerStartupGuard {
fn drop(&mut self) {
if self.join.is_none() {
return;
}
self.capture_server_cx();
// If the context has not arrived yet, dropping this receiver makes
// the server task fail closed before it binds a listener.
self.server_cx_rx = None;
if let Some(server_cx) = self.server_cx.as_ref() {
server_cx.set_cancel_requested(true);
}
let Some(finished) = self.finished.as_ref() else {
return;
};
let mut shutdown_completion = None;
let settlement =
await_http_server_shutdown(finished, &mut shutdown_completion, &mut self.join);
if settlement.is_err() && self.join.is_some() {
eprintln!(
"public HTTP server startup left a live unjoinable thread after bounded settlement"
);
std::process::abort();
}
}
}
impl HttpServerFixture {
fn spawn() -> Self {
Self::spawn_with_policy(ProtocolPolicy::Auto)
}
fn spawn_with_policy(protocol_policy: ProtocolPolicy) -> Self {
Self::spawn_with_policy_and_middleware(protocol_policy, None)
}
fn spawn_modern_with_page_size(page_size: usize) -> Self {
spawn_modern_facade_http_server(false, Some(page_size), false)
}
fn spawn_with_middleware(middleware: Option<Arc<dyn Middleware>>) -> Self {
Self::spawn_with_policy_and_middleware(ProtocolPolicy::Auto, middleware)
}
fn spawn_with_policy_and_middleware(
protocol_policy: ProtocolPolicy,
middleware: Option<Arc<dyn Middleware>>,
) -> Self {
Self::spawn_with_configuration(protocol_policy, middleware, None, false)
}
fn spawn_modern_tagged_catalog() -> Self {
Self::spawn_with_configuration(ProtocolPolicy::ModernOnly, None, None, true)
}
fn spawn_with_configuration(
protocol_policy: ProtocolPolicy,
middleware: Option<Arc<dyn Middleware>>,
page_size: Option<usize>,
tagged_catalog: bool,
) -> Self {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let tool_calls = Arc::clone(&handler_calls);
let resource_calls = Arc::clone(&handler_calls);
let prompt_calls = Arc::clone(&handler_calls);
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) =
mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("HTTP E2E server control receiver went away".to_owned());
}
let builder = ServerBuilder::new("facade-http-example", "1.0.0")
.protocol_policy(protocol_policy)
.expect("the fixture selects an available protocol policy");
let builder = match page_size {
Some(page_size) => builder.list_page_size(page_size),
None => builder,
};
let builder = builder
.tool(CountingPublicHttpValue {
counters: tool_calls,
})
.tool(PublicHttpCursorSecondary)
.tool(PublicHttpCursorOther)
.resource(CountingPublicHttpSnapshot {
counters: resource_calls,
})
.prompt(CountingPublicHttpInstruction {
counters: prompt_calls,
});
let builder = if tagged_catalog {
builder
.resource(PublicHttpCursorResourceAResource)
.prompt(PublicHttpCursorPromptAPrompt)
.resource(PublicHttpCursorTemplateResource)
.resource(PublicHttpOtherTemplateResource)
} else {
builder
};
let server = match middleware {
Some(middleware) => builder.middleware(middleware).build(),
None => builder.build(),
};
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("HTTP E2E startup receiver went away".to_owned());
}
bound
.serve(&cx)
.await
.map_err(|error| format!("facade HTTP server stopped unexpectedly: {error}"))
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("public HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("public HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("public HTTP server startup readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
Self {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
fn address(&self) -> SocketAddr {
self.address
}
fn handler_call_snapshot(&self) -> PublicHttpHandlerCallSnapshot {
self.handler_calls.snapshot()
}
fn settle(&mut self) -> Result<(), String> {
if let Some(shutdown) = self.nonquiescent.as_mut() {
return match runtime_block_on(shutdown.settle_for(HTTP_SERVER_TEARDOWN_BOUND)) {
HttpShutdownSettlement::Settled => {
self.nonquiescent = None;
Err("facade HTTP server stopped nonquiescently but settled during fixture cleanup"
.to_owned())
}
HttpShutdownSettlement::Failed { failures } => Err(format!(
"facade HTTP server child settlement observed {failures} terminal failure(s)"
)),
HttpShutdownSettlement::Pending { remaining } => Err(format!(
"facade HTTP server remains nonquiescent after bounded fixture cleanup ({remaining} retained children)"
)),
};
}
self.server_cx.set_cancel_requested(true);
match await_http_server_shutdown(
&self.finished,
&mut self.shutdown_completion,
&mut self.join,
)? {
HttpServerShutdown::Quiescent => Ok(()),
HttpServerShutdown::Nonquiescent(shutdown) => {
self.nonquiescent = Some(shutdown);
self.settle()
}
}
}
fn shutdown(mut self) {
self.settle()
.unwrap_or_else(|error| panic!("public HTTP server teardown failed: {error}"));
}
}
/// Starts the public ModernOnly facade with optional native bearer admission.
///
/// This fixture intentionally uses the facade builder instead of the lower
/// server crate so the test covers the public modern API's HTTP delegation.
fn spawn_modern_facade_http_server(
with_native_bearer_auth: bool,
page_size: Option<usize>,
with_resource: bool,
) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let tool_calls = Arc::clone(&handler_calls);
let resource_calls = Arc::clone(&handler_calls);
let prompt_calls = Arc::clone(&handler_calls);
let completion_calls = Arc::clone(&handler_calls);
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("authenticated HTTP server control receiver went away".to_owned());
}
let builder = modern::ServerBuilder::new("facade-native-http-auth", "1.0.0");
let builder = if with_native_bearer_auth {
let verifier = StaticTokenVerifier::new([(
"alpha",
AuthContext::with_subject("e2e-native-http-principal"),
)])
.expect("the deterministic native bearer verifier is valid")
.with_allowed_schemes(["Bearer"])
.expect("the bearer scheme allowlist is valid");
builder.auth_provider(TokenAuthProvider::new(verifier))
} else {
builder
};
let builder = builder.tool(CountingPublicHttpValue {
counters: tool_calls,
});
let builder = match page_size {
Some(page_size) => builder
.list_page_size(page_size)
.tool(PublicHttpCursorSecondary)
.tool(PublicHttpCursorOther),
None => builder,
};
let builder = if with_resource {
builder.resource(CountingPublicHttpSnapshot {
counters: resource_calls,
})
} else {
let _ = resource_calls;
builder
};
let server = builder
.prompt(CountingPublicHttpInstruction {
counters: prompt_calls,
})
.prompt_completion_handler(
PUBLIC_HTTP_PROMPT_NAME,
CountingPublicHttpCompletion {
counters: completion_calls,
},
)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("authenticated facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("authenticated facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("authenticated HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("authenticated facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("authenticated facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("authenticated facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("authenticated facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
/// Starts a public ModernOnly facade whose catalog is composed with `mount()`.
///
/// Tools and prompts come from a prefixed child (`child/...`). The resource
/// stays unprefixed so its exact final URI remains absolute.
fn spawn_modern_mounted_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("mounted HTTP server control receiver went away".to_owned());
}
let child = modern::ServerBuilder::new("facade-mounted-child", "1.0.0")
.tool(PublicHttpValue)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.build();
let server = modern::ServerBuilder::new("facade-mounted-parent", "1.0.0")
.mount(child, Some("child"))
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("mounted facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("mounted facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("mounted HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("mounted facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("mounted facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("mounted facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("mounted facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
impl Drop for HttpServerFixture {
fn drop(&mut self) {
if self.join.is_none() && self.nonquiescent.is_none() {
return;
}
if let Err(error) = self.settle() {
eprintln!("public HTTP server fixture drop failed: {error}");
// A `JoinHandle` dropped after a failed bounded settlement detaches
// a live server. Aborting is fail-closed in both normal and panic
// unwinding paths, after shutdown has already been requested.
std::process::abort();
}
}
}
/// Signals shutdown without blocking, waits for the owned thread's bounded
/// completion report, then joins only after the thread is known to have exited.
/// A timed-out owner remains retained for a controlled retry rather than being
/// detached.
fn await_http_server_shutdown(
finished: &mpsc::Receiver<Result<HttpServerShutdown, String>>,
completion: &mut Option<Result<HttpServerShutdown, String>>,
join: &mut Option<JoinHandle<()>>,
) -> Result<HttpServerShutdown, String> {
let completion_result = if completion.is_some() {
Ok(())
} else {
finished
.recv_timeout(HTTP_SERVER_TEARDOWN_BOUND)
.map(|shutdown| *completion = Some(shutdown))
.map_err(|error| format!("public HTTP server teardown exceeded its bound: {error}"))
};
let join_result = join_finished_thread(join, HTTP_SERVER_TEARDOWN_BOUND, "HTTP server");
match (completion_result, join_result) {
(Ok(()), Ok(())) => match completion
.take()
.expect("a completed server shutdown retains its completion report")
{
Ok(shutdown) => Ok(shutdown),
Err(completion) => Err(format!("public HTTP server teardown failed: {completion}")),
},
(Ok(()), Err(join)) => Err(join),
(Err(completion), Ok(())) => Err(completion),
(Err(completion), Err(join)) => Err(format!(
"{completion}; owned thread settlement failed: {join}"
)),
}
}
/// This helper is separately bounded so planted timeout tests can prove that
/// both an unread completion and a received-but-unjoined completion retain
/// their owner for a later controlled retry.
fn settle_http_server_with_bound(
shutdown: &mpsc::SyncSender<()>,
finished: &mpsc::Receiver<Result<(), String>>,
completion: &mut Option<Result<(), String>>,
join: &mut Option<JoinHandle<()>>,
completion_bound: Duration,
) -> Result<(), String> {
match shutdown.try_send(()) {
Ok(()) | Err(mpsc::TrySendError::Full(()) | mpsc::TrySendError::Disconnected(())) => {}
}
let completion_result = if completion.is_some() {
Ok(())
} else {
finished
.recv_timeout(completion_bound)
.map(|reported_completion| *completion = Some(reported_completion))
.map_err(|error| format!("public HTTP server teardown exceeded its bound: {error}"))
};
let join_result = join_finished_thread(join, completion_bound, "HTTP server");
match (completion_result, join_result) {
(Ok(()), Ok(())) => match completion
.take()
.expect("a completed server shutdown retains its completion report")
{
Ok(()) => Ok(()),
Err(completion) => Err(format!("public HTTP server teardown failed: {completion}")),
},
(Ok(()), Err(join)) => Err(join),
(Err(completion), Ok(())) => Err(completion),
(Err(completion), Err(join)) => Err(format!(
"{completion}; owned thread settlement failed: {join}"
)),
}
}
fn join_finished_thread(
join: &mut Option<JoinHandle<()>>,
completion_bound: Duration,
owner: &str,
) -> Result<(), String> {
let deadline = Instant::now() + completion_bound;
loop {
let Some(handle) = join.as_ref() else {
return Ok(());
};
if handle.is_finished() {
return join
.take()
.expect("completed thread retains its join handle")
.join()
.map_err(|_| format!("{owner} thread panicked during settlement"));
}
if Instant::now() >= deadline {
return Err(format!(
"{owner} reported completion but did not exit within its bounded settlement window"
));
}
thread::sleep(Duration::from_millis(1));
}
}
#[test]
fn e2e_http_fixture_planted_teardown_timeout_retains_owner_for_controlled_retry() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let (shutdown_tx, shutdown_rx) = mpsc::sync_channel::<()>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<(), String>>(1);
let (release_tx, release_rx) = mpsc::sync_channel::<()>(1);
let joined = Arc::new(AtomicBool::new(false));
let joined_by_server = Arc::clone(&joined);
let mut completion = None;
let mut server = Some(thread::spawn(move || {
shutdown_rx
.recv()
.expect("the bounded settlement path sends one shutdown signal");
release_rx
.recv()
.expect("the test releases the planted server after the deadline");
joined_by_server.store(true, Ordering::SeqCst);
let _ = finished_tx.send(Ok(()));
}));
let settlement = settle_http_server_with_bound(
&shutdown_tx,
&finished_rx,
&mut completion,
&mut server,
Duration::ZERO,
);
assert!(
!joined.load(Ordering::SeqCst),
"a timeout must not make an unbounded join attempt"
);
assert!(matches!(settlement, Err(error) if error.contains("exceeded its bound")));
assert!(
server.is_some(),
"the timed-out owner stays retained for an explicit controlled retry"
);
release_tx
.send(())
.expect("the retained server remains controllable after its deadline");
settle_http_server_with_bound(
&shutdown_tx,
&finished_rx,
&mut completion,
&mut server,
HTTP_SERVER_TEARDOWN_BOUND,
)
.expect("the released server settles without detaching its owner");
assert!(joined.load(Ordering::SeqCst));
assert!(server.is_none(), "settlement joins the retained owner");
}
#[test]
fn e2e_http_fixture_post_completion_join_timeout_retains_completion_for_retry() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let (shutdown_tx, shutdown_rx) = mpsc::sync_channel::<()>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<(), String>>(1);
let (completion_reported_tx, completion_reported_rx) = mpsc::sync_channel::<()>(1);
let (release_tx, release_rx) = mpsc::sync_channel::<()>(1);
let joined = Arc::new(AtomicBool::new(false));
let joined_by_server = Arc::clone(&joined);
let mut completion = None;
let mut server = Some(thread::spawn(move || {
shutdown_rx
.recv()
.expect("the controlled test sends one shutdown signal");
finished_tx
.send(Ok(()))
.expect("the completion receiver remains available before the join timeout");
completion_reported_tx
.send(())
.expect("the test observes the queued completion before settlement");
release_rx
.recv()
.expect("the test releases the server after its bounded join timeout");
joined_by_server.store(true, Ordering::SeqCst);
}));
shutdown_tx
.send(())
.expect("the test initiates shutdown before waiting for the completion report");
completion_reported_rx
.recv_timeout(HTTP_SERVER_TEARDOWN_BOUND)
.expect("the planted server reports completion within the bound");
let settlement = settle_http_server_with_bound(
&shutdown_tx,
&finished_rx,
&mut completion,
&mut server,
Duration::ZERO,
);
assert!(matches!(settlement, Err(error) if error.contains("reported completion")));
assert!(
matches!(completion.as_ref(), Some(Ok(()))),
"a failed bounded join retains the already-received completion for retry"
);
assert!(
server.is_some(),
"a post-completion join timeout retains the owned thread for controlled retry"
);
assert!(
!joined.load(Ordering::SeqCst),
"a received completion must not permit an unbounded join"
);
release_tx
.send(())
.expect("the retained server remains controllable after the join timeout");
settle_http_server_with_bound(
&shutdown_tx,
&finished_rx,
&mut completion,
&mut server,
HTTP_SERVER_TEARDOWN_BOUND,
)
.expect("retry consumes the retained completion only after joining the released owner");
assert!(joined.load(Ordering::SeqCst));
assert!(
completion.is_none(),
"successful settlement consumes the retained completion"
);
assert!(server.is_none(), "retry joins the retained owner");
}
#[test]
fn e2e_http_fixture_reported_teardown_failure_still_joins_owner() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let (shutdown_tx, shutdown_rx) = mpsc::sync_channel::<()>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<(), String>>(1);
let joined = Arc::new(AtomicBool::new(false));
let joined_by_server = Arc::clone(&joined);
let mut completion = None;
let mut server = Some(thread::spawn(move || {
shutdown_rx
.recv()
.expect("the bounded settlement path sends one shutdown signal");
joined_by_server.store(true, Ordering::SeqCst);
let _ = finished_tx.send(Err("planted server failure".to_owned()));
}));
let settlement = settle_http_server_with_bound(
&shutdown_tx,
&finished_rx,
&mut completion,
&mut server,
HTTP_SERVER_TEARDOWN_BOUND,
);
assert!(matches!(settlement, Err(error) if error.contains("planted server failure")));
assert!(joined.load(Ordering::SeqCst));
assert!(
server.is_none(),
"a reported server failure still joins its owner instead of detaching it"
);
}
#[test]
fn e2e_http_startup_guard_preserves_planted_startup_error() {
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let startup = HttpServerStartupGuard {
server_cx: Some(Cx::for_request()),
server_cx_rx: None,
finished: Some(finished_rx),
join: Some(thread::spawn(move || {
let _ = finished_tx.send(Err("planted startup failure".to_owned()));
})),
};
let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _startup = startup;
panic!("original startup diagnostic");
}))
.expect_err("the enclosing startup failure must propagate");
assert_eq!(
*panic
.downcast::<&str>()
.expect("the original startup diagnostic is retained"),
"original startup diagnostic"
);
}
#[test]
fn e2e_http_startup_guard_resumes_planted_pre_readiness_panic() {
let (_finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: None,
finished: Some(finished_rx),
join: Some(thread::spawn(|| panic!("planted pre-readiness panic"))),
};
let deadline = Instant::now() + HTTP_SERVER_TEARDOWN_BOUND;
while !startup
.join
.as_ref()
.expect("startup guard retains the spawned thread")
.is_finished()
{
assert!(
Instant::now() < deadline,
"the planted pre-readiness panic finishes within the bound"
);
thread::sleep(Duration::from_millis(1));
}
let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
startup.resume_thread_panic_if_finished();
}))
.expect_err("the original pre-readiness panic must be resumed");
assert_eq!(
*panic
.downcast::<&str>()
.expect("the original pre-readiness panic is retained"),
"planted pre-readiness panic"
);
assert!(startup.join.is_none());
}
fn plan(addr: SocketAddr, policy: ProtocolPolicy) -> ClientProtocolPlan {
plan_with_modern_post_path(addr, policy, "/mcp")
}
/// Builds one immutable public endpoint plan while allowing an Auto probe to
/// target a live server route that is intentionally not mounted as modern.
/// This supports real 404 fallback coverage without substituting a scripted
/// HTTP peer.
fn plan_with_modern_post_path(
addr: SocketAddr,
policy: ProtocolPolicy,
modern_post_path: &str,
) -> ClientProtocolPlan {
let modern_target = CanonicalHttpUrl::parse(&format!("http://{addr}{modern_post_path}"))
.expect("local modern target must be canonical");
let legacy_sse = CanonicalHttpUrl::parse(&format!("http://{addr}/sse"))
.expect("legacy SSE target must be canonical");
let legacy_message = CanonicalHttpUrl::parse(&format!("http://{addr}/messages"))
.expect("legacy message target must be canonical");
ClientProtocolPlan::http(
policy,
(!matches!(policy, ProtocolPolicy::LegacyOnly)).then_some(modern_target),
(!matches!(policy, ProtocolPolicy::ModernOnly)).then_some(legacy_sse),
(!matches!(policy, ProtocolPolicy::ModernOnly)).then_some(legacy_message),
"credential-partition-e2e-http".to_owned(),
"security-partition-e2e-http".to_owned(),
"native-h1-e2e-http".to_owned(),
1,
1,
0,
)
.expect("the complete HTTP plan must be accepted")
}
/// Consumes whichever response lane the real server selected and returns the
/// final correlated JSON-RPC response document.
fn final_response_document(cx: &Cx, response: ModernHttpResponseStream) -> serde_json::Value {
match response.metadata().kind() {
ModernHttpResponseKind::Json => {
let bytes = runtime_block_on_bounded(cx, response.read_to_end(cx, 1 << 20))
.expect("immediate JSON body reads to end");
serde_json::from_slice(&bytes).expect("immediate JSON response parses")
}
ModernHttpResponseKind::Sse => {
let mut stream = response
.into_sse_stream(SseLimits::new(65_536, 1 << 20, 64).expect("nonzero SSE limits"))
.expect("SSE response admits the shipped parser");
let mut terminal = None;
while let Some(event) = runtime_block_on_bounded(cx, stream.next_event(cx))
.expect("SSE stream stays readable")
{
let value: serde_json::Value =
serde_json::from_str(&event).expect("SSE payload is one JSON document");
if value.get("id").is_some() {
terminal = Some(value);
}
}
terminal.expect("SSE stream carried a final correlated response")
}
ModernHttpResponseKind::EmptyAcknowledgement => {
panic!("a correlated request cannot complete with an empty acknowledgement")
}
ModernHttpResponseKind::HttpFailure => panic!(
"unexpected HTTP failure status {} from the shipped server",
response.metadata().status()
),
}
}
#[derive(Debug, PartialEq)]
struct PublicHttpHandlerObservables {
tool: serde_json::Value,
resource: serde_json::Value,
prompt: serde_json::Value,
}
fn public_http_target(address: SocketAddr, path: &str) -> CanonicalHttpUrl {
CanonicalHttpUrl::parse(&format!("http://{address}{path}"))
.expect("a fixture route must form a canonical HTTP target")
}
fn assert_public_handler_observables(observables: &PublicHttpHandlerObservables) {
assert_eq!(
observables.tool["content"][0]["type"],
json!("text"),
"the public tool result must retain text content"
);
assert_eq!(
observables.tool["content"][0]["text"],
json!(PUBLIC_HTTP_TOOL_TEXT),
"the public tool result must retain the deterministic handler value"
);
assert_eq!(
observables.resource["contents"][0]["uri"],
json!(PUBLIC_HTTP_RESOURCE_URI),
"the public resource result must retain the registered URI"
);
assert_eq!(
observables.resource["contents"][0]["text"],
json!(PUBLIC_HTTP_RESOURCE_TEXT),
"the public resource result must retain the deterministic handler value"
);
assert_eq!(
observables.prompt["messages"][0]["role"],
json!("user"),
"the public prompt result must retain its message role"
);
assert_eq!(
observables.prompt["messages"][0]["content"]["type"],
json!("text"),
"the public prompt result must retain text content"
);
assert_eq!(
observables.prompt["messages"][0]["content"]["text"],
json!(PUBLIC_HTTP_PROMPT_TEXT),
"the public prompt result must retain the deterministic handler value"
);
}
fn invoke_modern_public_handlers(cx: &Cx, address: SocketAddr) -> PublicHttpHandlerObservables {
let mut client = runtime_block_on_bounded(
cx,
modern::ClientBuilder::new()
.client_info("e2e-public-modern-handler-client", "1.0.0")
.connect_http_with_cx(public_http_target(address, "/mcp"), cx),
)
.expect("the ModernOnly public facade connects to the live modern route");
let tool = runtime_block_on_bounded(
cx,
client.call_tool_with_mrtr_retry(
cx,
Instant::now() + HTTP_SERVER_TEARDOWN_BOUND,
PUBLIC_HTTP_TOOL_NAME,
json!({ "value": PUBLIC_HTTP_TOOL_ARGUMENT }),
SseLimits::new(65_536, 1 << 20, 64).expect("nonzero SSE limits"),
1 << 20,
|_| {
Err(McpError::invalid_request(
"the deterministic public tool must not request MRTR input",
))
},
),
)
.expect("the ModernOnly public facade invokes the live deterministic tool");
let modern::FinalCoreResult::ToolsCall { result: tool, .. } = tool else {
panic!("the deterministic public tool must complete without MRTR input");
};
let tool = serde_json::to_value(tool.payload)
.expect("the typed modern tool result serializes for its observable assertion");
let resource = runtime_block_on_bounded(cx, client.read_resource(cx, PUBLIC_HTTP_RESOURCE_URI))
.expect("the ModernOnly public facade reads the live deterministic resource");
let resource = serde_json::to_value(resource)
.expect("the typed modern resource result serializes for its observable assertion");
let prompt = runtime_block_on_bounded(
cx,
client.get_prompt(
cx,
PUBLIC_HTTP_PROMPT_NAME,
HashMap::from([("subject".to_owned(), PUBLIC_HTTP_TOOL_ARGUMENT.to_owned())]),
),
)
.expect("the ModernOnly public facade gets the live deterministic prompt");
let prompt = serde_json::to_value(prompt)
.expect("the typed modern prompt result serializes for its observable assertion");
let observables = PublicHttpHandlerObservables {
tool,
resource,
prompt,
};
assert_public_handler_observables(&observables);
observables
}
fn invoke_legacy_public_handlers(cx: &Cx, address: SocketAddr) -> PublicHttpHandlerObservables {
let mut client = runtime_block_on_bounded(
cx,
legacy_2024::http_client_builder(
public_http_target(address, "/sse"),
public_http_target(address, "/messages"),
)
.expect("the exact legacy HTTP endpoints form one public facade plan")
.client_info("e2e-public-legacy-handler-client", "1.0.0")
.connect_http_client_with_cx(cx),
)
.expect("the LegacyOnly public facade connects to the live exact-legacy routes");
assert_eq!(
client.protocol_policy(),
legacy_2024::ProtocolPolicy::LegacyOnly
);
let tool = runtime_block_on_bounded(
cx,
client.call_tool(
cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_TOOL_NAME.to_owned(),
arguments: Some(json!({ "value": PUBLIC_HTTP_TOOL_ARGUMENT })),
meta: None,
},
),
)
.expect("the LegacyOnly public facade invokes the live deterministic tool");
let tool = serde_json::to_value(tool)
.expect("the typed legacy tool result serializes for its observable assertion");
let resource = runtime_block_on_bounded(
cx,
client.read_resource(
cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_RESOURCE_URI.to_owned(),
meta: None,
},
),
)
.expect("the LegacyOnly public facade reads the live deterministic resource");
let resource = serde_json::to_value(resource)
.expect("the typed legacy resource result serializes for its observable assertion");
let prompt = runtime_block_on_bounded(
cx,
client.get_prompt(
cx,
legacy_2024::GetPromptParams {
name: PUBLIC_HTTP_PROMPT_NAME.to_owned(),
arguments: Some(HashMap::from([(
"subject".to_owned(),
PUBLIC_HTTP_TOOL_ARGUMENT.to_owned(),
)])),
meta: None,
},
),
)
.expect("the LegacyOnly public facade gets the live deterministic prompt");
let prompt = serde_json::to_value(prompt)
.expect("the typed legacy prompt result serializes for its observable assertion");
let observables = PublicHttpHandlerObservables {
tool,
resource,
prompt,
};
assert_public_handler_observables(&observables);
observables
}
#[derive(Default)]
struct OverlapFinalMethodGate {
state: Mutex<OverlapFinalMethodGateState>,
entered: Condvar,
released: Condvar,
}
#[derive(Default)]
struct OverlapFinalMethodGateState {
modern_tools_list_entered: bool,
legacy_ping_entered: bool,
modern_request_id: Option<serde_json::Value>,
legacy_request_id: Option<serde_json::Value>,
released: bool,
}
impl OverlapFinalMethodGate {
fn wait_for_cross_era_requests(&self) -> Result<(), String> {
let deadline = Instant::now() + HTTP_SERVER_TEARDOWN_BOUND;
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while !(state.modern_tools_list_entered && state.legacy_ping_entered) {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(
"the modern tools/list and legacy ping requests did not both reach the overlap gate"
.to_owned(),
);
}
let (next, timeout) = self
.entered
.wait_timeout(state, remaining)
.unwrap_or_else(std::sync::PoisonError::into_inner);
state = next;
if timeout.timed_out()
&& !(state.modern_tools_list_entered && state.legacy_ping_entered)
{
return Err(
"the modern tools/list and legacy ping requests did not both reach the overlap gate"
.to_owned(),
);
}
}
if state.modern_request_id != state.legacy_request_id {
return Err(format!(
"the overlapping modern and legacy requests used different IDs: modern={:?}, legacy={:?}",
state.modern_request_id, state.legacy_request_id
));
}
Ok(())
}
fn release(&self) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.released = true;
self.released.notify_all();
}
}
impl Middleware for OverlapFinalMethodGate {
fn on_request(
&self,
_ctx: &McpContext,
request: &JsonRpcRequest,
) -> McpResult<MiddlewareDecision> {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let request_id = serde_json::to_value(&request.id).map_err(|error| {
McpError::internal_error(format!("overlap request ID serializes: {error}"))
})?;
match request.method.as_str() {
"tools/list" => {
state.modern_tools_list_entered = true;
state.modern_request_id = Some(request_id);
}
"ping" => {
state.legacy_ping_entered = true;
state.legacy_request_id = Some(request_id);
}
_ => return Ok(MiddlewareDecision::Continue),
}
self.entered.notify_all();
while !state.released {
state = self
.released
.wait(state)
.unwrap_or_else(std::sync::PoisonError::into_inner);
}
Ok(MiddlewareDecision::Continue)
}
}
enum OverlapWorkerCompletion {
Modern(Result<serde_json::Value, String>),
Legacy(Result<serde_json::Value, String>),
}
/// Owns the planted stalled worker through both the deadline observation and
/// panic unwinding. Dropping it releases the worker before a bounded join.
struct StalledOverlapWorker {
release: mpsc::SyncSender<()>,
join: Option<JoinHandle<()>>,
}
impl StalledOverlapWorker {
fn release(&self) {
match self.release.try_send(()) {
Ok(()) | Err(mpsc::TrySendError::Full(()) | mpsc::TrySendError::Disconnected(())) => {}
}
}
fn settle(&mut self) -> Result<(), String> {
self.release();
join_finished_thread(
&mut self.join,
HTTP_SERVER_TEARDOWN_BOUND,
"stalled overlap worker",
)
}
}
impl Drop for StalledOverlapWorker {
fn drop(&mut self) {
if self.join.is_none() {
return;
}
if let Err(error) = self.settle() {
eprintln!("stalled overlap worker settlement failed: {error}");
std::process::abort();
}
}
}
/// Collects the two request outcomes within a deadline. Every caller must
/// then drive the workers to completion and join their retained owners.
fn collect_overlap_worker_results(
completed: &mpsc::Receiver<OverlapWorkerCompletion>,
completion_bound: Duration,
) -> (
Result<serde_json::Value, String>,
Result<serde_json::Value, String>,
) {
let deadline = Instant::now() + completion_bound;
let mut modern = None;
let mut legacy = None;
while modern.is_none() || legacy.is_none() {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
match completed.recv_timeout(remaining) {
Ok(OverlapWorkerCompletion::Modern(result)) if modern.is_none() => {
modern = Some(result);
}
Ok(OverlapWorkerCompletion::Legacy(result)) if legacy.is_none() => {
legacy = Some(result);
}
Ok(_) => {}
Err(mpsc::RecvTimeoutError::Timeout | mpsc::RecvTimeoutError::Disconnected) => break,
}
}
(
modern.unwrap_or_else(|| {
Err("the modern overlap worker did not complete before the bounded deadline".to_owned())
}),
legacy.unwrap_or_else(|| {
Err("the legacy overlap worker did not complete before the bounded deadline".to_owned())
}),
)
}
#[test]
fn e2e_overlap_worker_timeout_releases_fixture_teardown() {
let mut server = HttpServerFixture::spawn();
let (completed_tx, completed_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::sync_channel::<()>(1);
let stalled_completed_tx = completed_tx.clone();
let mut stalled_worker = StalledOverlapWorker {
release: release_tx,
join: Some(thread::spawn(move || {
release_rx
.recv()
.expect("the test releases the single stalled worker after its deadline");
let _ = stalled_completed_tx.send(OverlapWorkerCompletion::Modern(Ok(json!({}))));
})),
};
completed_tx
.send(OverlapWorkerCompletion::Legacy(Ok(json!({}))))
.expect("the non-stalled worker completion reaches the collector");
drop(completed_tx);
let (modern, legacy) = collect_overlap_worker_results(&completed_rx, Duration::from_millis(20));
assert!(
matches!(modern, Err(error) if error.contains("modern overlap worker")),
"only the deliberately stalled modern worker may exceed the completion deadline"
);
assert!(
legacy.is_ok(),
"the unchanged legacy worker completion is retained"
);
stalled_worker.release();
let completion = completed_rx
.recv_timeout(HTTP_SERVER_TEARDOWN_BOUND)
.expect("the released worker reports completion within its bounded cleanup window");
assert!(matches!(completion, OverlapWorkerCompletion::Modern(Ok(_))));
stalled_worker
.settle()
.expect("the released worker joins without detaching");
assert!(stalled_worker.join.is_none());
let teardown = server.settle();
teardown.expect("the bounded fixture teardown runs after a worker completion timeout");
}
#[test]
fn e2e_public_http_typed_facades_invoke_real_handlers_in_each_exact_era() {
let cx = Cx::for_request();
let modern_server = HttpServerFixture::spawn_with_policy(ProtocolPolicy::ModernOnly);
let modern = invoke_modern_public_handlers(&cx, modern_server.address());
assert_public_handler_observables(&modern);
modern_server.shutdown();
let legacy_server = HttpServerFixture::spawn_with_policy(ProtocolPolicy::LegacyOnly);
let legacy = invoke_legacy_public_handlers(&cx, legacy_server.address());
assert_public_handler_observables(&legacy);
legacy_server.shutdown();
}
#[test]
fn e2e_public_sse_constructor_invokes_live_legacy_handlers() {
let cx = Cx::for_request();
let server = HttpServerFixture::spawn_with_policy(ProtocolPolicy::LegacyOnly);
let mut client = runtime_block_on_bounded(
&cx,
fastmcp_rust::Client::sse_with_cx(
public_http_target(server.address(), "/sse"),
public_http_target(server.address(), "/messages"),
&cx,
),
)
.expect("Client::sse_with_cx connects exact-2024 SSE without probing modern HTTP");
let result = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_TOOL_NAME,
json!({ "value": PUBLIC_HTTP_TOOL_ARGUMENT }),
),
)
.expect("the standalone SSE constructor must invoke the live legacy tool");
let CoreResult::Legacy(legacy_2024::LegacyCoreResult::ToolsCall(result)) = result else {
panic!("Client::sse must stay on the exact-2024 tool result: {result:?}");
};
let tool = serde_json::to_value(result)
.expect("the exact-2024 SSE tool result serializes for its observable assertion");
assert_eq!(
tool["content"][0]["text"],
json!(PUBLIC_HTTP_TOOL_TEXT),
"Client::sse must retain the live handler value"
);
drop(client);
server.shutdown();
}
fn spawn_legacy_compose_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let tool_calls = Arc::clone(&handler_calls);
let resource_calls = Arc::clone(&handler_calls);
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("legacy compose HTTP server control receiver went away".to_owned());
}
let server = ServerBuilder::new("facade-http-legacy-compose", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(CountingPublicHttpValue {
counters: Arc::clone(&tool_calls),
})
.tool(PublicHttpComposeTool)
.resource(CountingPublicHttpSnapshot {
counters: resource_calls,
})
.resource(PublicHttpComposeResource {
uri: PUBLIC_HTTP_COMPOSE_RESOURCE_URI,
tool: PUBLIC_HTTP_TOOL_NAME,
resource: PUBLIC_HTTP_RESOURCE_URI,
})
.resource(PublicHttpComposeResource {
uri: PUBLIC_HTTP_COMPOSE_MISSING_TOOL_RESOURCE_URI,
tool: "public-http-e2e-missing",
resource: PUBLIC_HTTP_RESOURCE_URI,
})
.resource(PublicHttpComposeResource {
uri: PUBLIC_HTTP_COMPOSE_MISSING_RESOURCE_URI,
tool: PUBLIC_HTTP_TOOL_NAME,
resource: "test://public-http-e2e/missing",
})
.prompt(PublicHttpComposePrompt)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("legacy compose HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("legacy compose HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("legacy compose HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("legacy compose HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("legacy compose HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("legacy compose HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("legacy compose HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_sse_compose_nested_tool_and_resource() {
let cx = Cx::for_request();
let server = spawn_legacy_compose_http_server();
let mut client = runtime_block_on_bounded(
&cx,
fastmcp_rust::Client::sse_with_cx(
public_http_target(server.address(), "/sse"),
public_http_target(server.address(), "/messages"),
&cx,
),
)
.expect("Client::sse_with_cx connects exact-2024 SSE to the compose server");
let result = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_COMPOSE_TOOL_NAME,
json!({"value": "alpha"}),
),
)
.expect("exact-2024 SSE must compose a nested tool call and resource read");
let CoreResult::Legacy(legacy_2024::LegacyCoreResult::ToolsCall(result)) = result else {
panic!("Client::sse compose must stay on the exact-2024 tool result: {result:?}");
};
let tool = serde_json::to_value(result).expect("the exact-2024 SSE compose result serializes");
assert_eq!(
tool["content"][0]["text"],
json!("compose:tool:alpha|resource:deterministic"),
"exact-2024 session dispatch must attach request-scoped callers: {tool}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"the nested tools/call must invoke the peer tool exactly once"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"the nested resources/read must invoke the peer resource exactly once"
);
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_COMPOSE_TOOL_NAME,
json!({"value": "alpha", "tool": "public-http-e2e-missing"}),
),
);
let missing = match missing {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing.contains("public-http-e2e-missing")
|| missing.contains("compose-nested-tool")
|| missing.contains("Unknown tool")
|| missing.contains("not found"),
"the nested unknown tool must stay a handler-visible refusal: {missing}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"an unknown nested tool name must not invoke the peer tool"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_sse_prompt_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let server = spawn_legacy_compose_http_server();
let mut client = runtime_block_on_bounded(
&cx,
fastmcp_rust::Client::sse_with_cx(
public_http_target(server.address(), "/sse"),
public_http_target(server.address(), "/messages"),
&cx,
),
)
.expect("Client::sse_with_cx connects exact-2024 SSE to the prompt compose server");
let result = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
PUBLIC_HTTP_COMPOSE_PROMPT_NAME,
HashMap::from([("value".to_owned(), "alpha".to_owned())]),
),
)
.expect("exact-2024 SSE must compose a nested tool call and resource read from prompts/get");
let CoreResult::Legacy(legacy_2024::LegacyCoreResult::PromptsGet(result)) = result else {
panic!("Client::sse prompt compose must stay on the exact-2024 prompt result: {result:?}");
};
let prompt = serde_json::to_value(result).expect("the exact-2024 SSE prompt result serializes");
assert_eq!(
prompt["messages"][0]["content"]["text"],
json!("compose:tool:alpha|resource:deterministic"),
"exact-2024 session prompt dispatch must attach request-scoped callers: {prompt}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"the nested tools/call must invoke the peer tool exactly once"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"the nested resources/read must invoke the peer resource exactly once"
);
let missing = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
PUBLIC_HTTP_COMPOSE_PROMPT_NAME,
HashMap::from([
("value".to_owned(), "alpha".to_owned()),
("tool".to_owned(), "public-http-e2e-missing".to_owned()),
]),
),
);
let missing = match missing {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing.contains("public-http-e2e-missing")
|| missing.contains("compose-nested-tool")
|| missing.contains("Unknown tool")
|| missing.contains("not found"),
"the nested unknown tool must stay a handler-visible refusal: {missing}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"an unknown nested tool name must not invoke the peer tool"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_sse_resource_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let server = spawn_legacy_compose_http_server();
let mut client = runtime_block_on_bounded(
&cx,
fastmcp_rust::Client::sse_with_cx(
public_http_target(server.address(), "/sse"),
public_http_target(server.address(), "/messages"),
&cx,
),
)
.expect("Client::sse_with_cx connects exact-2024 SSE to the resource compose server");
let result = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_COMPOSE_RESOURCE_URI),
)
.expect("exact-2024 SSE must compose a nested tool call and resource read from resources/read");
let CoreResult::Legacy(legacy_2024::LegacyCoreResult::ResourcesRead(result)) = result else {
panic!(
"Client::sse resource compose must stay on the exact-2024 resource result: {result:?}"
);
};
let resource =
serde_json::to_value(result).expect("the exact-2024 SSE resource result serializes");
assert_eq!(
resource["contents"][0]["text"],
json!("compose:tool:alpha|resource:deterministic"),
"exact-2024 session resource dispatch must attach request-scoped callers: {resource}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"the nested tools/call must invoke the peer tool exactly once"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"the nested resources/read must invoke the peer resource exactly once"
);
let missing = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_COMPOSE_MISSING_TOOL_RESOURCE_URI),
);
let missing = match missing {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing.contains("public-http-e2e-missing")
|| missing.contains("compose-nested-tool")
|| missing.contains("Unknown tool")
|| missing.contains("not found"),
"the nested unknown tool must stay a handler-visible refusal: {missing}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"an unknown nested tool name must not invoke the peer tool"
);
drop(client);
server.shutdown();
}
fn spawn_legacy_caps_http_server(with_resource: bool) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("legacy caps HTTP server control receiver went away".to_owned());
}
let builder = ServerBuilder::new("facade-http-legacy-caps", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpCapsTool);
let builder = if with_resource {
builder.resource(PublicHttpSnapshotResource)
} else {
builder
};
let server = builder.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("legacy caps HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("legacy caps HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("legacy caps HTTP server startup receiver went away".to_owned());
}
bound
.serve(&cx)
.await
.map_err(|error| format!("legacy caps HTTP server stopped unexpectedly: {error}"))
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("legacy caps HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("legacy caps HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("legacy caps HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
fn legacy_caps_text(result: &legacy_2024::CallToolResult) -> String {
serde_json::to_value(result)
.ok()
.and_then(|value| value["content"][0]["text"].as_str().map(str::to_owned))
.unwrap_or_else(|| format!("missing text: {result:?}"))
}
#[test]
fn e2e_public_sse_handler_sees_initialized_client_capabilities() {
let cx = Cx::for_request();
let with_resources = spawn_legacy_caps_http_server(true);
let without_resources = spawn_legacy_caps_http_server(false);
let mut default_client = runtime_block_on_bounded(
&cx,
legacy_2024::http_client_builder(
public_http_target(with_resources.address(), "/sse"),
public_http_target(with_resources.address(), "/messages"),
)
.expect("default exact-2024 SSE endpoints form one plan")
.client_info("e2e-legacy-caps-default", "1.0.0")
.connect_http_client_with_cx(&cx),
)
.expect("the default exact-2024 client connects");
let default = runtime_block_on_bounded(
&cx,
default_client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_CAPS_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("default exact-2024 tools/call must reach the capability handler");
assert_eq!(
legacy_caps_text(&default),
"caps:sampling=false;roots=false;tools=true;resources=true",
"a default initialize must not invent sampling/roots, and a resource-bearing server must advertise both"
);
let mut sampling_client = runtime_block_on_bounded(
&cx,
legacy_2024::http_client_builder(
public_http_target(with_resources.address(), "/sse"),
public_http_target(with_resources.address(), "/messages"),
)
.expect("sampling exact-2024 SSE endpoints form one plan")
.client_info("e2e-legacy-caps-sampling", "1.0.0")
.capabilities(ClientCapabilities {
sampling: Some(Default::default()),
elicitation: None,
roots: Some(Default::default()),
})
.reverse_request_handlers(
legacy_2024::LegacyReverseRequestHandlers::new()
.with_sampling_create_message(|_cx, _cancel, _params| {
Box::pin(async {
Ok(legacy_2024::LegacyCreateMessageResult::text(
"e2e-unused-sample",
"e2e-unused-model",
))
})
})
.with_roots_list(|_cx, _cancel, _params| {
Box::pin(async { Ok(legacy_2024::LegacyListRootsResult::empty()) })
}),
)
.connect_http_client_with_cx(&cx),
)
.expect("the sampling/roots exact-2024 client connects");
let sampling = runtime_block_on_bounded(
&cx,
sampling_client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_CAPS_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("sampling exact-2024 tools/call must reach the capability handler");
assert_eq!(
legacy_caps_text(&sampling),
"caps:sampling=true;roots=true;tools=true;resources=true",
"initialize sampling+roots must reach ctx.client_capabilities on the exact-2024 session path"
);
let mut bare_client = runtime_block_on_bounded(
&cx,
legacy_2024::http_client_builder(
public_http_target(without_resources.address(), "/sse"),
public_http_target(without_resources.address(), "/messages"),
)
.expect("bare exact-2024 SSE endpoints form one plan")
.client_info("e2e-legacy-caps-bare", "1.0.0")
.connect_http_client_with_cx(&cx),
)
.expect("the default client connects to the tool-only exact-2024 server");
let bare = runtime_block_on_bounded(
&cx,
bare_client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_CAPS_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("tool-only exact-2024 tools/call must reach the capability handler");
assert_eq!(
legacy_caps_text(&bare),
"caps:sampling=false;roots=false;tools=true;resources=false",
"omitting the resource registration must clear only the handler-visible resources flag"
);
drop(default_client);
drop(sampling_client);
drop(bare_client);
with_resources.shutdown();
without_resources.shutdown();
}
#[cfg(feature = "proxy")]
fn spawn_modern_http_proxy_gateway(upstream: SocketAddr) -> HttpServerFixture {
spawn_modern_http_proxy_gateway_with_prefix(upstream, None)
}
#[cfg(feature = "proxy")]
fn spawn_modern_http_proxy_gateway_with_prefix(
upstream: SocketAddr,
prefix: Option<&'static str>,
) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("proxy gateway HTTP server reactor initializes"),
)
.build()
.expect("proxy gateway HTTP server installs an owned runtime");
let outcome = runtime.block_on(async move {
let cx =
Cx::current().expect("owned gateway runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("proxy gateway HTTP server control receiver went away".to_owned());
}
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream, "/mcp")),
None,
None,
"e2e-http-proxy-gateway".to_owned(),
"e2e-http-proxy-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.map_err(|error| format!("proxy gateway HTTP plan failed: {error}"))?;
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-live-upstream",
"native-h1:e2e-live-upstream",
1,
plan,
ClientInfo {
name: "e2e-http-proxy".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.map_err(|error| format!("live HTTP proxy upstream connect failed: {error}"))?;
let catalog = proxy
.catalog_typed()
.map_err(|error| format!("live HTTP proxy catalog failed: {error}"))?;
let tool_names = catalog
.final_tools()
.map(|tools| {
tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !tool_names.contains(&PUBLIC_HTTP_TOOL_NAME) {
return Err(format!(
"live HTTP proxy catalog omitted {PUBLIC_HTTP_TOOL_NAME}: {tool_names:?}"
));
}
let prompt_names = catalog
.final_prompts()
.map(|prompts| {
prompts
.iter()
.map(|prompt| prompt.name.as_str())
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !prompt_names.contains(&PUBLIC_HTTP_PROMPT_NAME) {
return Err(format!(
"live HTTP proxy catalog omitted {PUBLIC_HTTP_PROMPT_NAME}: {prompt_names:?}"
));
}
let resource_uris = catalog
.final_resources()
.map(|resources| {
resources
.iter()
.map(|resource| resource.uri.as_str())
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !resource_uris.contains(&PUBLIC_HTTP_RESOURCE_URI) {
return Err(format!(
"live HTTP proxy catalog omitted {PUBLIC_HTTP_RESOURCE_URI}: {resource_uris:?}"
));
}
let server = match prefix {
Some(prefix) => {
let server = modern::ServerBuilder::new("e2e-http-gateway", "1.0.0")
.as_proxy_typed(prefix, proxy, catalog)
.map_err(|error| format!("as_proxy_typed install failed: {error}"))?
.build();
if server.final_task_runtime().is_some() {
return Err(
"as_proxy_typed must install the route-bound Tasks relay instead of the default in-memory store"
.to_owned(),
);
}
server
}
None => modern::ServerBuilder::new("e2e-http-gateway", "1.0.0")
.proxy_typed(proxy, catalog)
.map_err(|error| format!("proxy_typed install failed: {error}"))?
.build(),
};
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("proxy gateway HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("proxy gateway HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("proxy gateway HTTP server startup receiver went away".to_owned());
}
bound
.serve(&cx)
.await
.map_err(|error| format!("proxy gateway HTTP server stopped unexpectedly: {error}"))
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("proxy gateway HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("proxy gateway HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("proxy gateway HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_proxy_gateway_forwards_live_bind_http_tool() {
let cx = Cx::for_request();
let upstream = spawn_modern_facade_http_server(false, None, true);
let gateway = spawn_modern_http_proxy_gateway(upstream.address());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-proxy-client", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP proxy gateway");
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("the live HTTP gateway must advertise the upstream tools/list catalog");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME),
"proxy_typed must retain the live upstream tool name: {listed:?}"
);
let result = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_TOOL_NAME,
json!({ "value": PUBLIC_HTTP_TOOL_ARGUMENT }),
),
)
.expect("the live HTTP gateway must forward tools/call to the upstream bind_http server");
assert!(
result.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == PUBLIC_HTTP_TOOL_TEXT,
_ => false,
}),
"the proxied live tool must retain the upstream handler value: {result:?}"
);
let listed_prompts = runtime_block_on_bounded(&cx, client.list_prompts(&cx, None))
.expect("the live HTTP gateway must advertise the upstream prompts/list catalog");
assert!(
listed_prompts
.prompts
.iter()
.any(|prompt| prompt.name == PUBLIC_HTTP_PROMPT_NAME),
"proxy_typed must retain the live upstream prompt name: {listed_prompts:?}"
);
let prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
PUBLIC_HTTP_PROMPT_NAME,
HashMap::from([("subject".to_owned(), PUBLIC_HTTP_TOOL_ARGUMENT.to_owned())]),
),
)
.expect("the live HTTP gateway must forward prompts/get to the upstream bind_http server");
let prompt = serde_json::to_value(prompt)
.expect("the proxied prompt result serializes for its observable assertion");
assert_eq!(
prompt["messages"][0]["content"]["text"],
json!(PUBLIC_HTTP_PROMPT_TEXT),
"the proxied live prompt must retain the upstream handler value: {prompt:?}"
);
let listed_resources = runtime_block_on_bounded(&cx, client.list_resources(&cx, None))
.expect("the live HTTP gateway must advertise the upstream resources/list catalog");
assert!(
listed_resources
.resources
.iter()
.any(|resource| resource.uri.as_str() == PUBLIC_HTTP_RESOURCE_URI),
"proxy_typed must retain the live upstream resource URI: {listed_resources:?}"
);
let resource =
runtime_block_on_bounded(&cx, client.read_resource(&cx, PUBLIC_HTTP_RESOURCE_URI)).expect(
"the live HTTP gateway must forward resources/read to the upstream bind_http server",
);
assert!(
matches!(
resource.contents.as_slice(),
[EmbeddedResourceContents::Text { text, .. }] if text == PUBLIC_HTTP_RESOURCE_TEXT
),
"the proxied live resource must retain the upstream handler value: {:?}",
resource.contents
);
let completion = runtime_block_on_bounded(
&cx,
client.complete(
&cx,
modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: PUBLIC_HTTP_PROMPT_NAME.to_owned(),
title: "Public HTTP E2E Prompt".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
context: Some(modern::FinalCompletionContext {
arguments: Some(std::collections::BTreeMap::from([(
"region".to_owned(),
"us-east-1".to_owned(),
)])),
}),
},
),
)
.expect(
"the live HTTP gateway must forward completion/complete to the upstream bind_http server",
);
assert_eq!(
completion.completion.values,
vec![PUBLIC_HTTP_COMPLETION_VALUE.to_owned()],
"the proxied live completion must retain the upstream provider values: {completion:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_prefixed_as_proxy_forwards_prefixed_tool_and_unprefixed_resource() {
const PREFIXED_TOOL_NAME: &str = "ext/public-http-e2e-tool";
const PREFIXED_PROMPT_NAME: &str = "ext/public-http-e2e-prompt";
let cx = Cx::for_request();
let upstream = spawn_modern_facade_http_server(false, None, true);
let gateway = spawn_modern_http_proxy_gateway_with_prefix(upstream.address(), Some("ext"));
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-as-proxy-client", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live prefixed as_proxy HTTP gateway");
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("the live prefixed gateway must advertise the upstream tools/list catalog");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == PREFIXED_TOOL_NAME),
"as_proxy_typed must prefix the live upstream tool name: {listed:?}"
);
assert!(
!listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME),
"a nonempty as_proxy prefix must not keep the unprefixed upstream tool: {listed:?}"
);
let result = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PREFIXED_TOOL_NAME,
json!({ "value": PUBLIC_HTTP_TOOL_ARGUMENT }),
),
)
.expect("the live prefixed gateway must forward tools/call to the upstream bind_http server");
assert!(
result.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == PUBLIC_HTTP_TOOL_TEXT,
_ => false,
}),
"the prefixed live tool must retain the upstream handler value: {result:?}"
);
let listed_prompts = runtime_block_on_bounded(&cx, client.list_prompts(&cx, None))
.expect("the live prefixed gateway must advertise the upstream prompts/list catalog");
assert!(
listed_prompts
.prompts
.iter()
.any(|prompt| prompt.name == PREFIXED_PROMPT_NAME),
"as_proxy_typed must prefix the live upstream prompt name: {listed_prompts:?}"
);
let prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
PREFIXED_PROMPT_NAME,
HashMap::from([("subject".to_owned(), PUBLIC_HTTP_TOOL_ARGUMENT.to_owned())]),
),
)
.expect("the live prefixed gateway must forward prompts/get to the upstream bind_http server");
let prompt = serde_json::to_value(prompt)
.expect("the prefixed prompt result serializes for its observable assertion");
assert_eq!(
prompt["messages"][0]["content"]["text"],
json!(PUBLIC_HTTP_PROMPT_TEXT),
"the prefixed live prompt must retain the upstream handler value: {prompt:?}"
);
let listed_resources = runtime_block_on_bounded(&cx, client.list_resources(&cx, None)).expect(
"the live prefixed gateway must advertise the exact upstream resources/list catalog",
);
assert!(
listed_resources
.resources
.iter()
.any(|resource| resource.uri.as_str() == PUBLIC_HTTP_RESOURCE_URI),
"as_proxy_typed must keep the exact final resource URI: {listed_resources:?}"
);
assert!(
listed_resources
.resources
.iter()
.all(|resource| { !resource.uri.as_str().starts_with("ext/") }),
"a nonempty as_proxy prefix must not invent a non-absolute modern resource URI: {listed_resources:?}"
);
let resource = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_RESOURCE_URI),
)
.expect(
"the live prefixed gateway must forward resources/read to the upstream bind_http server",
);
assert!(
matches!(
resource.contents.as_slice(),
[EmbeddedResourceContents::Text { text, .. }] if text == PUBLIC_HTTP_RESOURCE_TEXT
),
"the prefixed live resource must retain the upstream handler value: {:?}",
resource.contents
);
let completion = runtime_block_on_bounded(
&cx,
client.complete(
&cx,
modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: PREFIXED_PROMPT_NAME.to_owned(),
title: "Public HTTP E2E Prompt".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
context: Some(modern::FinalCompletionContext {
arguments: Some(std::collections::BTreeMap::from([(
"region".to_owned(),
"us-east-1".to_owned(),
)])),
}),
},
),
)
.expect(
"the live prefixed gateway must forward completion/complete to the upstream bind_http server",
);
assert_eq!(
completion.completion.values,
vec![PUBLIC_HTTP_COMPLETION_VALUE.to_owned()],
"the prefixed live completion must retain the upstream provider values: {completion:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
fn spawn_modern_task_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("task HTTP server control receiver went away".to_owned());
}
let task_runtime = FinalTaskRuntime::in_memory(
FinalTaskRuntimeConfig::new(60_000, Some(5_000))
.expect("task HTTP server timing policy is valid"),
Arc::new(|_| {}),
);
let task_runner = task_runtime
.install_task_service(1, Arc::new(PublicHttpHoldingTaskSupervisor))
.map_err(|error| format!("task HTTP server service install failed: {error}"))?;
let server = modern::ServerBuilder::new("facade-http-task", "1.0.0")
.tool(PublicHttpTaskTool)
.tool(PublicHttpTouchTool)
.tool(PublicHttpHideTool)
.final_tasks(task_runtime)
.map_err(|error| format!("task HTTP server final_tasks install failed: {error}"))?
.build();
let mut service = std::pin::pin!(task_runner.run(&cx));
std::future::poll_fn(|task_context| match service.as_mut().poll(task_context) {
std::task::Poll::Pending => std::task::Poll::Ready(Ok(())),
std::task::Poll::Ready(Ok(())) => {
std::task::Poll::Ready(Err("task HTTP service stopped before bind".to_owned()))
}
std::task::Poll::Ready(Err(error)) => std::task::Poll::Ready(Err(format!(
"task HTTP service failed before bind: {error}"
))),
})
.await?;
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("task facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("task facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("task HTTP server startup receiver went away".to_owned());
}
let mut serving = std::pin::pin!(bound.serve(&cx));
let mut service_stopped = false;
std::future::poll_fn(|task_context| {
if !service_stopped {
match service.as_mut().poll(task_context) {
std::task::Poll::Ready(Ok(())) if cx.checkpoint().is_ok() => {
return std::task::Poll::Ready(Err(
"task HTTP service stopped while the server remained live"
.to_owned(),
));
}
std::task::Poll::Ready(Err(error)) if cx.checkpoint().is_ok() => {
return std::task::Poll::Ready(Err(format!(
"task HTTP service failed while the server remained live: {error}"
)));
}
std::task::Poll::Ready(_) => service_stopped = true,
std::task::Poll::Pending => {}
}
}
match serving.as_mut().poll(task_context) {
std::task::Poll::Ready(Ok(shutdown)) => std::task::Poll::Ready(Ok(shutdown)),
std::task::Poll::Ready(Err(error)) => std::task::Poll::Ready(Err(format!(
"task facade HTTP server stopped unexpectedly: {error}"
))),
std::task::Poll::Pending => std::task::Poll::Pending,
}
})
.await
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("task facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("task facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("task facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
fn spawn_modern_http_task_proxy_gateway(upstream: SocketAddr) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("task proxy gateway HTTP server reactor initializes"),
)
.build()
.expect("task proxy gateway HTTP server installs an owned runtime");
let outcome = runtime.block_on(async move {
let cx =
Cx::current().expect("owned gateway runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("task proxy gateway HTTP server control receiver went away".to_owned());
}
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream, "/mcp")),
None,
None,
"e2e-http-task-proxy-gateway".to_owned(),
"e2e-http-task-proxy-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.map_err(|error| format!("task proxy gateway HTTP plan failed: {error}"))?;
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-live-task-upstream",
"native-h1:e2e-live-task-upstream",
1,
plan,
ClientInfo {
name: "e2e-http-task-proxy".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.map_err(|error| format!("live HTTP task proxy upstream connect failed: {error}"))?;
let catalog = proxy
.catalog_typed()
.map_err(|error| format!("live HTTP task proxy catalog failed: {error}"))?;
let tool_names = catalog
.final_tools()
.map(|tools| {
tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !tool_names.contains(&PUBLIC_HTTP_TASK_TOOL_NAME) {
return Err(format!(
"live HTTP task proxy catalog omitted {PUBLIC_HTTP_TASK_TOOL_NAME}: {tool_names:?}"
));
}
let server = modern::ServerBuilder::new("e2e-http-task-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.map_err(|error| format!("as_proxy_typed task install failed: {error}"))?
.build();
if server.final_task_runtime().is_some() {
return Err(
"as_proxy_typed must install the route-bound Tasks relay instead of the default in-memory store"
.to_owned(),
);
}
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("task proxy gateway HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("task proxy gateway HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("task proxy gateway HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("task proxy gateway HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("task proxy gateway HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("task proxy gateway HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("task proxy gateway HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_prefixed_as_proxy_gets_upstream_created_task() {
let cx = Cx::for_request();
let upstream = spawn_modern_task_http_server();
let gateway = spawn_modern_http_task_proxy_gateway(upstream.address());
let mut upstream_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-task-upstream-client", "1.0.0")
.connect_http_with_cx(public_http_target(upstream.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live task upstream HTTP server");
let created = runtime_block_on_bounded(
&cx,
upstream_client.call_tool_outcome(
&cx,
RequestId::Number(2),
PUBLIC_HTTP_TASK_TOOL_NAME,
json!({}),
1 << 20,
),
)
.expect("the live upstream must create one official Task");
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the task-capable live tool must return the official Task result branch: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
drop(upstream_client);
let mut gateway_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-task-gateway-client", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live prefixed as_proxy Tasks gateway");
let input_deadline = Instant::now() + HTTP_OPERATION_BOUND;
let mut next_get_id = 4_i64;
let observed = loop {
let observed = runtime_block_on_bounded(
&cx,
gateway_client.get_task(
&cx,
RequestId::Number(next_get_id),
task_id.clone(),
1 << 20,
),
)
.expect("the live prefixed gateway must forward tasks/get to the upstream store");
next_get_id = next_get_id
.checked_add(1)
.expect("the official Tasks request id space does not overflow");
if matches!(observed.task, FinalTask::InputRequired { .. }) {
break observed;
}
if Instant::now() >= input_deadline {
panic!(
"as_proxy_typed must observe the upstream input_required Task rather than a disconnected local store: {observed:?}"
);
}
thread::sleep(Duration::from_millis(5));
};
assert_eq!(
observed.task.base().task_id,
task_id,
"as_proxy_typed must return the upstream-created Task rather than a disconnected local store: {observed:?}"
);
let missing = FinalTaskId::parse("missing-upstream-task")
.expect("the planted-missing task id is a valid official TaskId");
let missing_get = runtime_block_on_bounded(
&cx,
gateway_client.get_task(
&cx,
RequestId::Number(next_get_id),
missing.clone(),
1 << 20,
),
)
.expect_err("changing only the task id must not invent a Task on the gateway");
let _ = missing_get;
next_get_id += 1;
let wrong_update: FinalTaskInputResponses =
serde_json::from_value(json!({"roots": {"action": "accept"}}))
.expect("the planted-wrong Tasks update payload is typed");
let rejected_update = runtime_block_on_bounded(
&cx,
gateway_client.update_task(
&cx,
RequestId::Number(next_get_id),
&observed.task,
wrong_update,
1 << 20,
),
)
.expect_err("changing only the input response kind must not resume the upstream Task");
let _ = rejected_update;
next_get_id += 1;
let still_waiting = runtime_block_on_bounded(
&cx,
gateway_client.get_task(
&cx,
RequestId::Number(next_get_id),
task_id.clone(),
1 << 20,
),
)
.expect("a rejected tasks/update must leave the upstream Task in place");
next_get_id += 1;
assert!(
matches!(still_waiting.task, FinalTask::InputRequired { .. }),
"a rejected tasks/update must not leave the input_required Task: {still_waiting:?}"
);
let responses: FinalTaskInputResponses =
serde_json::from_value(json!({"roots": {"roots": []}}))
.expect("the public final roots response is typed");
runtime_block_on_bounded(
&cx,
gateway_client.update_task(
&cx,
RequestId::Number(next_get_id),
&observed.task,
responses,
1 << 20,
),
)
.expect("the live prefixed gateway must forward tasks/update to the upstream store");
next_get_id += 1;
let working_deadline = Instant::now() + HTTP_OPERATION_BOUND;
let working = loop {
let observed = runtime_block_on_bounded(
&cx,
gateway_client.get_task(
&cx,
RequestId::Number(next_get_id),
task_id.clone(),
1 << 20,
),
)
.expect("the live prefixed gateway must keep forwarding tasks/get after update");
next_get_id = next_get_id
.checked_add(1)
.expect("the official Tasks request id space does not overflow");
if matches!(observed.task, FinalTask::Working(_)) {
break observed;
}
if Instant::now() >= working_deadline {
panic!(
"as_proxy_typed must observe the upstream working Task after a matching update: {observed:?}"
);
}
thread::sleep(Duration::from_millis(5));
};
assert_eq!(
working.task.base().task_id,
task_id,
"as_proxy_typed must resume the same upstream-created Task: {working:?}"
);
let missing_cancel = runtime_block_on_bounded(
&cx,
gateway_client.cancel_task(&cx, RequestId::Number(next_get_id), missing, 1 << 20),
)
.expect_err("changing only the task id must not invent a cancellation on the gateway");
let _ = missing_cancel;
next_get_id += 1;
runtime_block_on_bounded(
&cx,
gateway_client.cancel_task(
&cx,
RequestId::Number(next_get_id),
task_id.clone(),
1 << 20,
),
)
.expect("the live prefixed gateway must forward tasks/cancel to the upstream store");
next_get_id += 1;
let cancel_deadline = Instant::now() + HTTP_OPERATION_BOUND;
let cancelled = loop {
let observed = runtime_block_on_bounded(
&cx,
gateway_client.get_task(
&cx,
RequestId::Number(next_get_id),
task_id.clone(),
1 << 20,
),
)
.expect("the live prefixed gateway must keep forwarding tasks/get after cancel");
next_get_id = next_get_id
.checked_add(1)
.expect("the official Tasks request id space does not overflow");
if matches!(observed.task, FinalTask::Cancelled(_)) {
break observed;
}
if Instant::now() >= cancel_deadline {
panic!(
"as_proxy_typed must observe the upstream cancelled Task rather than a disconnected local store: {observed:?}"
);
}
thread::sleep(Duration::from_millis(5));
};
assert_eq!(
cancelled.task.base().task_id,
task_id,
"as_proxy_typed must cancel the same upstream-created Task: {cancelled:?}"
);
drop(gateway_client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
fn spawn_modern_http_stdio_as_proxy_gateway() -> (HttpServerFixture, FinalTaskId) {
let (gateway, task_id) = spawn_modern_http_stdio_as_proxy_gateway_configured(true, Vec::new());
(
gateway,
task_id.expect("the precreate path always records one official Task"),
)
}
/// Same live stdio `as_proxy("ext")` HTTP gateway, but without occupying the
/// shipped echo in-memory store (capacity 1). Create-through-gateway proofs
/// use this so `ext/durable_task` is not immediately capacity-refused.
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
fn spawn_modern_http_stdio_as_proxy_gateway_without_precreated_task() -> HttpServerFixture {
spawn_modern_http_stdio_as_proxy_gateway_configured(false, Vec::new()).0
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
fn spawn_modern_http_stdio_as_proxy_gateway_configured(
precreate_task: bool,
extra_env: Vec<(String, String)>,
) -> (HttpServerFixture, Option<FinalTaskId>) {
spawn_modern_http_stdio_as_proxy_gateway_with_options(
precreate_task,
extra_env,
false,
None,
None,
false,
false,
false,
false,
None,
)
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
fn spawn_modern_http_stdio_as_proxy_gateway_with_options(
precreate_task: bool,
extra_env: Vec<(String, String)>,
mask_error_details: bool,
request_timeout_secs: Option<u64>,
cache_tools: Option<Vec<String>>,
rate_limit: bool,
sliding_window: bool,
static_token: bool,
strict_input: bool,
list_page_size: Option<usize>,
) -> (HttpServerFixture, Option<FinalTaskId>) {
spawn_modern_http_stdio_as_proxy_gateway_with_duplicate(
precreate_task,
extra_env,
mask_error_details,
request_timeout_secs,
cache_tools,
rate_limit,
sliding_window,
static_token,
strict_input,
list_page_size,
None,
)
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
fn spawn_modern_http_stdio_as_proxy_gateway_with_duplicate(
precreate_task: bool,
extra_env: Vec<(String, String)>,
mask_error_details: bool,
request_timeout_secs: Option<u64>,
cache_tools: Option<Vec<String>>,
rate_limit: bool,
sliding_window: bool,
static_token: bool,
strict_input: bool,
list_page_size: Option<usize>,
on_duplicate: Option<DuplicateBehavior>,
) -> (HttpServerFixture, Option<FinalTaskId>) {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) =
mpsc::sync_channel::<Result<(SocketAddr, Option<FinalTaskId>), String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("stdio as_proxy gateway HTTP server reactor initializes"),
)
.build()
.expect("stdio as_proxy gateway HTTP server installs an owned runtime");
let outcome = runtime.block_on(async move {
let cx =
Cx::current().expect("owned gateway runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("stdio as_proxy gateway HTTP server control receiver went away".to_owned());
}
let mut last_connect_error = None;
let mut stdio = None;
for attempt in 1_u32..=4 {
let mut builder = modern::ClientBuilder::new()
.client_info("e2e-http-stdio-as-proxy-upstream", "1.0.0")
.env("FASTMCP_PROTOCOL_POLICY", "modern-only");
for (key, value) in &extra_env {
builder = builder.env(key.clone(), value.clone());
}
match builder
.max_retries(2)
.retry_delay_ms(150)
.connect_stdio_with_cx(env!("CARGO_BIN_EXE_echo_server"), &[], &cx)
{
Ok(mut client) => {
if precreate_task {
stdio = Some(client);
break;
}
// as_proxy catalog listing is the first stdio request
// on this path. A live tools/list first proves the
// echo process is answering. An idle-deadline miss
// retires this client so the next attempt reconnects.
match client.list_tools(None) {
Ok(_) => {
stdio = Some(client);
break;
}
Err(error) => {
last_connect_error = Some(error);
drop(client);
if attempt < 4 {
thread::sleep(Duration::from_millis(50 * u64::from(attempt)));
}
}
}
}
Err(error) => {
last_connect_error = Some(error);
if attempt < 4 {
thread::sleep(Duration::from_millis(50 * u64::from(attempt)));
}
}
}
}
let mut stdio = stdio.ok_or_else(|| {
format!(
"live stdio as_proxy upstream connect failed: {}",
last_connect_error
.expect("a failed stdio connect records its last transport error")
)
})?;
let task_id = if precreate_task {
let created = stdio
.call_tool_outcome("durable_task", json!({}))
.map_err(|error| {
format!("live stdio as_proxy upstream must create one official Task: {error}")
})?;
let FinalToolCallOutcome::Task(created) = created else {
return Err(format!(
"the shipped durable_task tool must return the official Task result branch: {created:?}"
));
};
Some(created.task.base().task_id.clone())
} else {
None
};
let mut builder = modern::ServerBuilder::new("e2e-http-stdio-as-proxy", "1.0.0")
.mask_error_details(mask_error_details);
if let Some(request_timeout_secs) = request_timeout_secs {
builder = builder.request_timeout(request_timeout_secs);
}
if let Some(cache_tools) = cache_tools {
builder = builder.middleware(
caching::ResponseCachingMiddleware::new().include_tools(cache_tools),
);
}
if rate_limit {
builder = builder.middleware(
rate_limiting::RateLimitingMiddleware::new(1.0e-300).burst_capacity(1),
);
}
if sliding_window {
builder = builder.middleware(
rate_limiting::SlidingWindowRateLimitingMiddleware::new(1, 60),
);
}
if static_token {
let verifier = StaticTokenVerifier::new([(
"alpha",
AuthContext::with_subject(PUBLIC_HTTP_AUTH_SUBJECT),
)])
.expect("the deterministic stdio as_proxy gateway bearer verifier is valid")
.with_allowed_schemes(["Bearer"])
.expect("the stdio as_proxy gateway bearer scheme allowlist is valid");
builder = builder.auth_provider(TokenAuthProvider::new(verifier));
}
if strict_input {
builder = builder.strict_input_validation(true);
}
if let Some(page_size) = list_page_size {
builder = builder.list_page_size(page_size);
}
if let Some(behavior) = on_duplicate {
builder = builder.on_duplicate(behavior).tool(LocalPrefixedEcho);
}
let server = builder
.as_proxy("ext", stdio)
.map_err(|error| format!("as_proxy stdio install failed: {error}"))?
.build();
if server.final_task_runtime().is_some() {
return Err(
"as_proxy must install the route-bound Tasks relay instead of the default in-memory store"
.to_owned(),
);
}
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("stdio as_proxy gateway HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("stdio as_proxy gateway HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok((address, task_id))).is_err() {
cx.set_cancel_requested(true);
return Err("stdio as_proxy gateway HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("stdio as_proxy gateway HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let (address, task_id) = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("stdio as_proxy gateway HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(ready)) => break ready,
Ok(Err(error)) => panic!("stdio as_proxy gateway HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("stdio as_proxy gateway HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
(
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
},
task_id,
)
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_forwards_prefixed_echo() {
let cx = Cx::for_request();
let (gateway, task_id) = spawn_modern_http_stdio_as_proxy_gateway();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-stdio-as-proxy-client", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy HTTP gateway");
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("the live stdio as_proxy gateway must advertise the prefixed echo catalog");
assert!(
listed.tools.iter().any(|tool| tool.name == "ext/echo"),
"as_proxy must prefix the live stdio echo tool: {listed:?}"
);
assert!(
!listed.tools.iter().any(|tool| tool.name == "echo"),
"a nonempty as_proxy prefix must not keep the unprefixed stdio echo tool: {listed:?}"
);
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "echo", json!({"message": "nope"})),
)
.expect_err("changing only the tool name must not reach the unprefixed stdio handler");
let _ = missing;
let result = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({ "message": "stdio-as-proxy" })),
)
.expect("the live stdio as_proxy gateway must forward tools/call to the shipped echo server");
assert!(
result.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "stdio-as-proxy",
_ => false,
}),
"the prefixed live stdio tool must retain the echo handler value: {result:?}"
);
let hidden = runtime_block_on_bounded(&cx, client.call_tool(&cx, "ext/hide_echo", json!({})))
.expect(
"the live stdio as_proxy gateway must forward hide_echo to the shipped echo session",
);
assert!(
hidden.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "hidden",
_ => false,
}),
"as_proxy must retain the echo disable_tool mutation: {hidden:?}"
);
let still_listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None)).expect(
"the live stdio as_proxy gateway must keep its install-time catalog after hide_echo",
);
assert!(
still_listed
.tools
.iter()
.any(|tool| tool.name == "ext/echo"),
"as_proxy must not drop the prefixed echo tool from the gateway snapshot: {still_listed:?}"
);
let disabled = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({ "message": "after-hide" })),
)
.expect("hide_echo on the live stdio session must keep the public tools/call result");
assert!(
disabled.is_error,
"hide_echo on the live stdio session must mark a later ext/echo call as a tool error: {disabled:?}"
);
assert!(
disabled.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text.contains("Method not found"),
_ => false,
}),
"the refused ext/echo call must keep the session-disabled MethodNotFound tool error: {disabled:?}"
);
let added = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/add", json!({ "a": 2, "b": 3 })),
)
.expect("changing only the tool name must still reach an undisabled stdio handler");
assert!(
added.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "5",
_ => false,
}),
"as_proxy must keep undisabled stdio tools callable after hide_echo: {added:?}"
);
let shown = runtime_block_on_bounded(&cx, client.call_tool(&cx, "ext/show_echo", json!({})))
.expect(
"the live stdio as_proxy gateway must forward show_echo to the shipped echo session",
);
assert!(
shown.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "shown",
_ => false,
}),
"as_proxy must retain the echo enable_tool mutation: {shown:?}"
);
let restored = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({ "message": "after-show" })),
)
.expect("show_echo on the live stdio session must restore the prefixed echo tool");
assert!(
restored.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "after-show",
_ => false,
}),
"as_proxy must keep the restored stdio echo tool callable: {restored:?}"
);
let listed_resources = runtime_block_on_bounded(&cx, client.list_resources(&cx, None))
.expect("the live stdio as_proxy gateway must advertise the exact upstream resource URIs");
assert!(
listed_resources
.resources
.iter()
.any(|resource| resource.uri.as_str() == "info://server"),
"as_proxy must keep the live stdio resource URI unprefixed: {listed_resources:?}"
);
assert!(
!listed_resources
.resources
.iter()
.any(|resource| resource.uri.as_str().contains("ext/")),
"as_proxy must not prefix exact-final resource URIs: {listed_resources:?}"
);
let resource = runtime_block_on_bounded(&cx, client.read_resource(&cx, "info://server"))
.expect("the live stdio as_proxy gateway must forward the unprefixed resource URI");
assert!(
resource.contents.iter().any(|content| match content {
EmbeddedResourceContents::Text { text, .. } => text.contains("echo-server"),
_ => false,
}),
"the unprefixed live stdio resource must retain the echo handler value: {resource:?}"
);
let listed_prompts = runtime_block_on_bounded(&cx, client.list_prompts(&cx, None))
.expect("the live stdio as_proxy gateway must advertise the prefixed prompt catalog");
assert!(
listed_prompts
.prompts
.iter()
.any(|prompt| prompt.name == "ext/greeting"),
"as_proxy must prefix the live stdio greeting prompt: {listed_prompts:?}"
);
let missing_prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
"greeting",
HashMap::from([("name".to_owned(), "Ada".to_owned())]),
),
)
.expect_err("changing only the prompt name must not reach the unprefixed stdio handler");
let _ = missing_prompt;
let prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
"ext/greeting",
HashMap::from([("name".to_owned(), "Ada".to_owned())]),
),
)
.expect("the live stdio as_proxy gateway must forward prompts/get to the shipped echo server");
let prompt = serde_json::to_value(prompt)
.expect("the prefixed stdio prompt result serializes for its observable assertion");
assert_eq!(
prompt["messages"][0]["content"]["text"],
json!("Please greet Ada in a friendly way."),
"the prefixed live stdio prompt must retain the echo handler value: {prompt:?}"
);
let completion_params = modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: "ext/greeting".to_owned(),
title: "Greeting".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "name".to_owned(),
value: "co".to_owned(),
},
context: Some(modern::FinalCompletionContext {
arguments: Some(std::collections::BTreeMap::from([(
"locale".to_owned(),
"en-US".to_owned(),
)])),
}),
};
let mut unprefixed_completion = completion_params.clone();
unprefixed_completion.reference = modern::CompletionReference::PromptWithTitle {
name: "greeting".to_owned(),
title: "Greeting".to_owned(),
};
let missing_completion =
runtime_block_on_bounded(&cx, client.complete(&cx, unprefixed_completion)).expect_err(
"changing only the completion prompt name must not reach the stdio provider",
);
let _ = missing_completion;
let completion = runtime_block_on_bounded(&cx, client.complete(&cx, completion_params)).expect(
"the live stdio as_proxy gateway must rewrite ext/greeting and forward completion/complete",
);
assert_eq!(
completion.completion.values,
vec!["stdio-completion-1".to_owned()],
"the prefixed live stdio completion must retain the echo provider value: {completion:?}"
);
let hidden_catalog =
runtime_block_on_bounded(&cx, client.call_tool(&cx, "ext/hide_catalog", json!({}))).expect(
"the live stdio as_proxy gateway must forward hide_catalog to the shipped echo session",
);
assert!(
hidden_catalog.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "hidden",
_ => false,
}),
"as_proxy must retain the echo disable_resource and disable_prompt mutations: {hidden_catalog:?}"
);
let still_listed_resources =
runtime_block_on_bounded(&cx, client.list_resources(&cx, None)).expect(
"the live stdio as_proxy gateway must keep its install-time resource catalog after hide_catalog",
);
assert!(
still_listed_resources
.resources
.iter()
.any(|resource| resource.uri.as_str() == "info://server"),
"as_proxy must not drop the unprefixed resource URI from the gateway snapshot: {still_listed_resources:?}"
);
let still_listed_prompts = runtime_block_on_bounded(&cx, client.list_prompts(&cx, None)).expect(
"the live stdio as_proxy gateway must keep its install-time prompt catalog after hide_catalog",
);
assert!(
still_listed_prompts
.prompts
.iter()
.any(|prompt| prompt.name == "ext/greeting"),
"as_proxy must not drop the prefixed greeting prompt from the gateway snapshot: {still_listed_prompts:?}"
);
let cached_resource = runtime_block_on_bounded(&cx, client.read_resource(&cx, "info://server"))
.expect(
"the warmed HTTP client must keep its cached info://server read after hide_catalog",
);
assert!(
cached_resource
.contents
.iter()
.any(|content| match content {
EmbeddedResourceContents::Text { text, .. } => text.contains("echo-server"),
_ => false,
}),
"as_proxy must not invent a resources/list_changed that would drop the client cache: {cached_resource:?}"
);
let mut fresh_after_hide = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-stdio-as-proxy-hide-catalog", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("a second HTTP client connects after hide_catalog");
let disabled_resource = runtime_block_on_bounded(
&cx,
fresh_after_hide.read_resource(&cx, "info://server"),
)
.expect_err(
"hide_catalog on the live stdio session must refuse a later uncached info://server read",
);
let disabled_resource = format!("{disabled_resource:?}");
assert!(
disabled_resource.contains("disabled") && disabled_resource.contains("info://server"),
"the refused resource read must be the session-disabled upstream URI: {disabled_resource}"
);
drop(fresh_after_hide);
let disabled_prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
"ext/greeting",
HashMap::from([("name".to_owned(), "Bea".to_owned())]),
),
)
.expect_err("hide_catalog on the live stdio session must refuse a later ext/greeting get");
let disabled_prompt = format!("{disabled_prompt:?}");
assert!(
disabled_prompt.contains("disabled") && disabled_prompt.contains("greeting"),
"the refused prompt get must be the session-disabled upstream prompt: {disabled_prompt}"
);
let shown_catalog =
runtime_block_on_bounded(&cx, client.call_tool(&cx, "ext/show_catalog", json!({}))).expect(
"the live stdio as_proxy gateway must forward show_catalog to the shipped echo session",
);
assert!(
shown_catalog.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "shown",
_ => false,
}),
"as_proxy must retain the echo enable_resource and enable_prompt mutations: {shown_catalog:?}"
);
let mut fresh_after_show = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-stdio-as-proxy-show-catalog", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("a third HTTP client connects after show_catalog");
let restored_resource =
runtime_block_on_bounded(&cx, fresh_after_show.read_resource(&cx, "info://server")).expect(
"show_catalog on the live stdio session must restore an uncached info://server read",
);
assert!(
restored_resource
.contents
.iter()
.any(|content| match content {
EmbeddedResourceContents::Text { text, .. } => text.contains("echo-server"),
_ => false,
}),
"as_proxy must keep the restored stdio resource readable: {restored_resource:?}"
);
drop(fresh_after_show);
let restored_prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
"ext/greeting",
HashMap::from([("name".to_owned(), "Cyd".to_owned())]),
),
)
.expect("show_catalog on the live stdio session must restore the prefixed greeting prompt");
let restored_prompt = serde_json::to_value(restored_prompt)
.expect("the restored stdio prompt result serializes for its observable assertion");
assert_eq!(
restored_prompt["messages"][0]["content"]["text"],
json!("Please greet Cyd in a friendly way."),
"as_proxy must keep the restored stdio prompt callable: {restored_prompt:?}"
);
let input_deadline = Instant::now() + HTTP_OPERATION_BOUND;
let mut next_input_id = 40_i64;
let observed = loop {
let observed = runtime_block_on_bounded(
&cx,
client.get_task(
&cx,
RequestId::Number(next_input_id),
task_id.clone(),
1 << 20,
),
)
.expect("the live stdio as_proxy gateway must forward tasks/get to the echo store");
next_input_id = next_input_id
.checked_add(1)
.expect("the official Tasks request id space does not overflow");
if matches!(observed.task, FinalTask::InputRequired { .. }) {
break observed;
}
if Instant::now() >= input_deadline {
panic!(
"as_proxy must observe the stdio-created input_required Task rather than a disconnected local store: {observed:?}"
);
}
thread::sleep(Duration::from_millis(5));
};
assert_eq!(
observed.task.base().task_id,
task_id,
"as_proxy must return the stdio-created Task rather than a disconnected local store: {observed:?}"
);
let missing_id = FinalTaskId::parse("missing-stdio-upstream-task")
.expect("the planted-missing task id is a valid official TaskId");
let missing_get = runtime_block_on_bounded(
&cx,
client.get_task(&cx, RequestId::Number(41), missing_id.clone(), 1 << 20),
)
.expect_err("changing only the task id must not invent a Task on the stdio as_proxy gateway");
let _ = missing_get;
let responses: FinalTaskInputResponses =
serde_json::from_value(json!({"roots": {"roots": []}}))
.expect("the public final roots response is typed");
runtime_block_on_bounded(
&cx,
client.update_task(
&cx,
RequestId::Number(42),
&observed.task,
responses,
1 << 20,
),
)
.expect("the live stdio as_proxy gateway must forward tasks/update to the echo store");
let working_deadline = Instant::now() + HTTP_OPERATION_BOUND;
let mut next_get_id = 43_i64;
let working = loop {
let observed = runtime_block_on_bounded(
&cx,
client.get_task(
&cx,
RequestId::Number(next_get_id),
task_id.clone(),
1 << 20,
),
)
.expect("the live stdio as_proxy gateway must keep forwarding tasks/get after update");
next_get_id = next_get_id
.checked_add(1)
.expect("the official Tasks request id space does not overflow");
if matches!(observed.task, FinalTask::Working(_)) {
break observed;
}
if Instant::now() >= working_deadline {
panic!(
"as_proxy must observe the stdio-created working Task after a matching update: {observed:?}"
);
}
thread::sleep(Duration::from_millis(5));
};
assert_eq!(
working.task.base().task_id,
task_id,
"as_proxy must resume the same stdio-created Task: {working:?}"
);
let missing_cancel = runtime_block_on_bounded(
&cx,
client.cancel_task(&cx, RequestId::Number(next_get_id), missing_id, 1 << 20),
)
.expect_err(
"changing only the task id must not invent a cancellation on the stdio as_proxy gateway",
);
let _ = missing_cancel;
next_get_id += 1;
runtime_block_on_bounded(
&cx,
client.cancel_task(
&cx,
RequestId::Number(next_get_id),
task_id.clone(),
1 << 20,
),
)
.expect("the live stdio as_proxy gateway must forward tasks/cancel to the echo store");
next_get_id += 1;
let cancel_deadline = Instant::now() + HTTP_OPERATION_BOUND;
let cancelled = loop {
let observed = runtime_block_on_bounded(
&cx,
client.get_task(
&cx,
RequestId::Number(next_get_id),
task_id.clone(),
1 << 20,
),
)
.expect("the live stdio as_proxy gateway must keep forwarding tasks/get after cancel");
next_get_id = next_get_id
.checked_add(1)
.expect("the official Tasks request id space does not overflow");
if matches!(observed.task, FinalTask::Cancelled(_)) {
break observed;
}
if Instant::now() >= cancel_deadline {
panic!(
"as_proxy must observe the stdio-created cancelled Task rather than a disconnected local store: {observed:?}"
);
}
thread::sleep(Duration::from_millis(5));
};
assert_eq!(
cancelled.task.base().task_id,
task_id,
"as_proxy must cancel the same stdio-created Task: {cancelled:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "tasks"))]
#[test]
fn e2e_public_stdio_hide_catalog_refuses_later_read_and_prompt() {
let cx = Cx::for_request();
let mut client = modern::ClientBuilder::new()
.client_info("e2e-stdio-hide-catalog", "1.0.0")
.env("FASTMCP_PROTOCOL_POLICY", "modern-only")
.max_retries(2)
.retry_delay_ms(150)
.connect_stdio_with_cx(env!("CARGO_BIN_EXE_echo_server"), &[], &cx)
.expect("a ModernOnly facade client connects to the shipped echo server");
let before = client
.read_resource("info://server")
.expect("the shipped echo resource must be readable before hide_catalog");
assert!(
before.contents.iter().any(|content| match content {
EmbeddedResourceContents::Text { text, .. } => text.contains("echo-server"),
_ => false,
}),
"the live stdio resource must retain the echo handler value: {before:?}"
);
let hidden = client
.call_tool("hide_catalog", json!({}))
.expect("disabling a shipped resource and prompt must complete");
assert!(
hidden.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "hidden",
_ => false,
}),
"stdio session state must retain disable_resource and disable_prompt: {hidden:?}"
);
let disabled_resource = client.read_resource("info://server").expect_err(
"hide_catalog must refuse a later info://server read on the same stdio session",
);
let disabled_resource = format!("{disabled_resource:?}");
assert!(
disabled_resource.contains("disabled") && disabled_resource.contains("info://server"),
"the refused resource read must be the session-disabled URI: {disabled_resource}"
);
let disabled_prompt = client
.get_prompt(
"greeting",
HashMap::from([("name".to_owned(), "Bea".to_owned())]),
)
.expect_err("hide_catalog must refuse a later greeting get on the same stdio session");
let disabled_prompt = format!("{disabled_prompt:?}");
assert!(
disabled_prompt.contains("disabled") && disabled_prompt.contains("greeting"),
"the refused prompt get must be the session-disabled prompt: {disabled_prompt}"
);
let shown = client
.call_tool("show_catalog", json!({}))
.expect("re-enabling a shipped resource and prompt must complete");
assert!(
shown.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "shown",
_ => false,
}),
"stdio session state must retain enable_resource and enable_prompt: {shown:?}"
);
let restored = client
.read_resource("info://server")
.expect("show_catalog must restore a later info://server read on the same stdio session");
assert!(
restored.contents.iter().any(|content| match content {
EmbeddedResourceContents::Text { text, .. } => text.contains("echo-server"),
_ => false,
}),
"the restored stdio resource must retain the echo handler value: {restored:?}"
);
let restored_prompt = client
.get_prompt(
"greeting",
HashMap::from([("name".to_owned(), "Cyd".to_owned())]),
)
.expect("show_catalog must restore a later greeting get on the same stdio session");
let restored_prompt = serde_json::to_value(restored_prompt)
.expect("the restored stdio prompt result serializes for its observable assertion");
assert_eq!(
restored_prompt["messages"][0]["content"]["text"],
json!("Please greet Cyd in a friendly way."),
"the restored stdio prompt must retain the echo handler value: {restored_prompt:?}"
);
client
.close()
.expect("modern-only stdio hide_catalog client cleanup");
}
#[cfg(feature = "proxy")]
fn spawn_modern_http_template_proxy_gateway(
upstream: SocketAddr,
expected_template: &'static str,
) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("template proxy gateway HTTP server reactor initializes"),
)
.build()
.expect("template proxy gateway HTTP server installs an owned runtime");
let outcome = runtime.block_on(async move {
let cx =
Cx::current().expect("owned gateway runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err(
"template proxy gateway HTTP server control receiver went away".to_owned(),
);
}
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream, "/mcp")),
None,
None,
"e2e-http-template-proxy-gateway".to_owned(),
"e2e-http-template-proxy-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.map_err(|error| format!("template proxy gateway HTTP plan failed: {error}"))?;
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-live-template-upstream",
"native-h1:e2e-live-template-upstream",
1,
plan,
ClientInfo {
name: "e2e-http-template-proxy".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.map_err(|error| {
format!("live HTTP template proxy upstream connect failed: {error}")
})?;
let catalog = proxy
.catalog_typed()
.map_err(|error| format!("live HTTP template proxy catalog failed: {error}"))?;
let templates = catalog
.final_resource_templates()
.map(|templates| {
templates
.iter()
.map(|template| template.uri_template.as_str())
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !templates.contains(&expected_template) {
return Err(format!(
"live HTTP template proxy catalog omitted {expected_template}: {templates:?}"
));
}
let server = modern::ServerBuilder::new("e2e-http-template-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.map_err(|error| format!("as_proxy_typed template install failed: {error}"))?
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message =
format!("template proxy gateway HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("template proxy gateway HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err(
"template proxy gateway HTTP server startup receiver went away".to_owned(),
);
}
bound.serve(&cx).await.map_err(|error| {
format!("template proxy gateway HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("template proxy gateway HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("template proxy gateway HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("template proxy gateway HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_prefixed_as_proxy_forwards_unprefixed_resource_template() {
let cx = Cx::for_request();
let (upstream, reads) = spawn_modern_template_http_server();
let gateway =
spawn_modern_http_template_proxy_gateway(upstream.address(), PUBLIC_HTTP_TEMPLATE);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-as-proxy-template-client", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live template as_proxy HTTP gateway");
let listed = runtime_block_on_bounded(&cx, client.list_resource_templates(&cx, None))
.expect("the live prefixed gateway must advertise the exact upstream resource template");
assert!(
listed
.resource_templates
.iter()
.any(|template| template.uri_template == PUBLIC_HTTP_TEMPLATE
&& template.name == PUBLIC_HTTP_TEMPLATE_NAME),
"as_proxy_typed must keep the exact final resource template: {:?}",
listed.resource_templates
);
assert!(
listed
.resource_templates
.iter()
.all(|template| !template.uri_template.starts_with("ext/")),
"a nonempty as_proxy prefix must not invent a non-absolute template URI: {:?}",
listed.resource_templates
);
let matched = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_TEMPLATE_MATCHED_URI),
)
.expect("the live prefixed gateway must expand a URI that matches the upstream template");
assert!(
matches!(
matched.contents.as_slice(),
[EmbeddedResourceContents::Text { text, .. }] if text == "item:alpha"
),
"the proxied matched template read must retain the extracted id: {:?}",
matched.contents
);
assert_eq!(
reads.load(Ordering::SeqCst),
1,
"a matching proxied resources/read must invoke the upstream templated handler once"
);
let unmatched = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_TEMPLATE_UNMATCHED_URI),
)
.expect_err(
"changing only the path that the template cannot bind must refuse before upstream dispatch",
);
assert!(
matches!(
unmatched,
modern::HttpClientError::CoreResult(ref error)
if error.code == McpErrorCode::InvalidParams
),
"an unmatched proxied template URI must stay InvalidParams: {unmatched:?}"
);
assert_eq!(
reads.load(Ordering::SeqCst),
1,
"the unmatched URI must leave the upstream templated handler uninvoked"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[test]
fn e2e_public_http_mount_forwards_prefixed_tool_and_unprefixed_resource() {
const MOUNTED_TOOL_NAME: &str = "child/public-http-e2e-tool";
const MOUNTED_PROMPT_NAME: &str = "child/public-http-e2e-prompt";
let cx = Cx::for_request();
let server = spawn_modern_mounted_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-mount-client", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live mounted HTTP server");
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("the mounted parent must advertise the prefixed child tools/list catalog");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == MOUNTED_TOOL_NAME),
"mount() must rewrite the child tool name: {listed:?}"
);
assert!(
!listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME),
"a nonempty mount prefix must not keep the unprefixed child tool: {listed:?}"
);
let result = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
MOUNTED_TOOL_NAME,
json!({ "value": PUBLIC_HTTP_TOOL_ARGUMENT }),
),
)
.expect("the mounted parent must dispatch tools/call through the prefixed child handler");
assert!(
result.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == PUBLIC_HTTP_TOOL_TEXT,
_ => false,
}),
"the mounted live tool must retain the child handler value: {result:?}"
);
let listed_prompts = runtime_block_on_bounded(&cx, client.list_prompts(&cx, None))
.expect("the mounted parent must advertise the prefixed child prompts/list catalog");
assert!(
listed_prompts
.prompts
.iter()
.any(|prompt| prompt.name == MOUNTED_PROMPT_NAME),
"mount() must rewrite the child prompt name: {listed_prompts:?}"
);
let prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
MOUNTED_PROMPT_NAME,
HashMap::from([("subject".to_owned(), PUBLIC_HTTP_TOOL_ARGUMENT.to_owned())]),
),
)
.expect("the mounted parent must dispatch prompts/get through the prefixed child handler");
let prompt = serde_json::to_value(prompt)
.expect("the mounted prompt result serializes for its observable assertion");
assert_eq!(
prompt["messages"][0]["content"]["text"],
json!(PUBLIC_HTTP_PROMPT_TEXT),
"the mounted live prompt must retain the child handler value: {prompt:?}"
);
let listed_resources = runtime_block_on_bounded(&cx, client.list_resources(&cx, None))
.expect("the unprefixed mount must advertise the exact child resources/list catalog");
assert!(
listed_resources
.resources
.iter()
.any(|resource| resource.uri.as_str() == PUBLIC_HTTP_RESOURCE_URI),
"an unprefixed mount must keep the exact final resource URI: {listed_resources:?}"
);
assert!(
listed_resources
.resources
.iter()
.all(|resource| { !resource.uri.as_str().starts_with("child/") }),
"a nonempty prefix must not invent a non-absolute modern resource URI: {listed_resources:?}"
);
let resource =
runtime_block_on_bounded(&cx, client.read_resource(&cx, PUBLIC_HTTP_RESOURCE_URI))
.expect("the unprefixed mount must dispatch resources/read through the child handler");
assert!(
matches!(
resource.contents.as_slice(),
[EmbeddedResourceContents::Text { text, .. }] if text == PUBLIC_HTTP_RESOURCE_TEXT
),
"the mounted live resource must retain the child handler value: {:?}",
resource.contents
);
drop(client);
server.shutdown();
}
const PUBLIC_HTTP_FS_PREFIX: &str = "e2e";
const PUBLIC_HTTP_FS_TEMPLATE: &str = "file:///e2e/{+path}";
const PUBLIC_HTTP_FS_FILE_NAME: &str = "note.txt";
const PUBLIC_HTTP_FS_FILE_URI: &str = "file:///e2e/note.txt";
const PUBLIC_HTTP_FS_FILE_TEXT: &str = "filesystem:deterministic";
/// Starts a public ModernOnly facade whose catalog is one live FilesystemProvider.
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn spawn_modern_filesystem_http_server() -> HttpServerFixture {
spawn_modern_filesystem_http_server_named("direct")
}
fn spawn_modern_filesystem_http_server_named(label: &'static str) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let root = std::env::temp_dir().join(format!(
"fastmcp-public-http-fs-e2e-{}-{label}",
std::process::id()
));
std::fs::create_dir_all(&root).expect("the filesystem e2e root is created");
std::fs::write(
root.join(PUBLIC_HTTP_FS_FILE_NAME),
PUBLIC_HTTP_FS_FILE_TEXT,
)
.expect("the filesystem e2e file is written");
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("filesystem HTTP server control receiver went away".to_owned());
}
let handler = providers::FilesystemProvider::new(&root)
.with_prefix(PUBLIC_HTTP_FS_PREFIX)
.with_exclude(&[])
.build()
.map_err(|error| format!("FilesystemProvider::build failed: {error}"))?;
let server = if label == "mounted" {
let child = modern::ServerBuilder::new("facade-filesystem-child", "1.0.0")
.resource(handler)
.build();
modern::ServerBuilder::new("facade-filesystem-parent", "1.0.0")
.mount(child, Some("child"))
.build()
} else {
modern::ServerBuilder::new("facade-filesystem-http", "1.0.0")
.resource(handler)
.build()
};
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("filesystem facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("filesystem facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("filesystem HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("filesystem facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("filesystem facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("filesystem facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("filesystem facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn e2e_public_http_filesystem_provider_lists_and_reads_live_file() {
let cx = Cx::for_request();
let server = spawn_modern_filesystem_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-fs-client", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live filesystem HTTP server");
let listed = runtime_block_on_bounded(&cx, client.list_resource_templates(&cx, None))
.expect("live bind_http must list the FilesystemProvider template");
assert!(
listed
.resource_templates
.iter()
.any(|template| template.uri_template == PUBLIC_HTTP_FS_TEMPLATE
&& template.name == PUBLIC_HTTP_FS_PREFIX),
"FilesystemProvider must advertise its reversible file template: {:?}",
listed.resource_templates
);
let unmatched =
runtime_block_on_bounded(&cx, client.read_resource(&cx, "file:///other/note.txt"))
.expect_err(
"changing only the prefix the template cannot bind must refuse before dispatch",
);
assert!(
matches!(
unmatched,
modern::HttpClientError::CoreResult(ref error)
if error.code == McpErrorCode::InvalidParams
),
"an unmatched filesystem URI must stay InvalidParams: {unmatched:?}"
);
let file = runtime_block_on_bounded(&cx, client.read_resource(&cx, PUBLIC_HTTP_FS_FILE_URI))
.expect("resources/read must expand the live file URI through the filesystem handler");
assert!(
matches!(
file.contents.as_slice(),
[EmbeddedResourceContents::Text { text, .. }] if text == PUBLIC_HTTP_FS_FILE_TEXT
),
"the live filesystem read must retain the file bytes: {:?}",
file.contents
);
drop(client);
server.shutdown();
}
#[cfg(all(feature = "proxy", any(target_os = "linux", target_os = "macos")))]
#[test]
fn e2e_public_http_prefixed_as_proxy_forwards_filesystem_template() {
let cx = Cx::for_request();
let upstream = spawn_modern_filesystem_http_server_named("as-proxy");
let gateway =
spawn_modern_http_template_proxy_gateway(upstream.address(), PUBLIC_HTTP_FS_TEMPLATE);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-as-proxy-fs-client", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live filesystem as_proxy HTTP gateway");
let listed = runtime_block_on_bounded(&cx, client.list_resource_templates(&cx, None))
.expect("the live prefixed gateway must advertise the exact filesystem template");
assert!(
listed
.resource_templates
.iter()
.any(|template| template.uri_template == PUBLIC_HTTP_FS_TEMPLATE
&& template.name == PUBLIC_HTTP_FS_PREFIX),
"as_proxy_typed must keep the exact filesystem template: {:?}",
listed.resource_templates
);
assert!(
listed
.resource_templates
.iter()
.all(|template| !template.uri_template.starts_with("ext/")),
"a nonempty as_proxy prefix must not invent a non-absolute filesystem template URI: {:?}",
listed.resource_templates
);
let unmatched = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, "file:///other/note.txt"),
)
.expect_err(
"changing only the prefix the template cannot bind must refuse before upstream dispatch",
);
assert!(
matches!(
unmatched,
modern::HttpClientError::CoreResult(ref error)
if error.code == McpErrorCode::InvalidParams
),
"an unmatched proxied filesystem URI must stay InvalidParams: {unmatched:?}"
);
let file = runtime_block_on_bounded(&cx, client.read_resource(&cx, PUBLIC_HTTP_FS_FILE_URI))
.expect("the live prefixed gateway must expand the live file URI through the upstream filesystem handler");
assert!(
matches!(
file.contents.as_slice(),
[EmbeddedResourceContents::Text { text, .. }] if text == PUBLIC_HTTP_FS_FILE_TEXT
),
"the proxied live filesystem read must retain the file bytes: {:?}",
file.contents
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn e2e_public_http_mount_forwards_unprefixed_filesystem_template() {
let cx = Cx::for_request();
let server = spawn_modern_filesystem_http_server_named("mounted");
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-mount-fs-client", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live mounted filesystem HTTP server");
let listed = runtime_block_on_bounded(&cx, client.list_resource_templates(&cx, None))
.expect("the mounted parent must advertise the exact filesystem template");
assert!(
listed
.resource_templates
.iter()
.any(|template| template.uri_template == PUBLIC_HTTP_FS_TEMPLATE
&& template.name == PUBLIC_HTTP_FS_PREFIX),
"mount() must keep the exact filesystem template: {:?}",
listed.resource_templates
);
assert!(
listed
.resource_templates
.iter()
.all(|template| !template.uri_template.starts_with("child/")),
"a nonempty mount prefix must not invent a non-absolute filesystem template URI: {:?}",
listed.resource_templates
);
let unmatched = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, "file:///other/note.txt"),
)
.expect_err(
"changing only the prefix the template cannot bind must refuse before the child handler",
);
assert!(
matches!(
unmatched,
modern::HttpClientError::CoreResult(ref error)
if error.code == McpErrorCode::InvalidParams
),
"an unmatched mounted filesystem URI must stay InvalidParams: {unmatched:?}"
);
let file = runtime_block_on_bounded(&cx, client.read_resource(&cx, PUBLIC_HTTP_FS_FILE_URI))
.expect(
"the mounted parent must expand the live file URI through the child filesystem handler",
);
assert!(
matches!(
file.contents.as_slice(),
[EmbeddedResourceContents::Text { text, .. }] if text == PUBLIC_HTTP_FS_FILE_TEXT
),
"the mounted live filesystem read must retain the file bytes: {:?}",
file.contents
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_modern_facade_native_http_negotiates_then_dispatches_authenticated_tool() {
const MAX_NATIVE_HTTP_RESPONSE_BYTES: usize = 1 << 20;
fn exchange(
address: SocketAddr,
protocol_version: &str,
method: &str,
tool_name: Option<&str>,
body: &[u8],
) -> Vec<u8> {
let mut stream = std::net::TcpStream::connect_timeout(&address, HTTP_OPERATION_BOUND)
.expect("native HTTP client connects to the public facade listener");
stream
.set_read_timeout(Some(HTTP_OPERATION_BOUND))
.expect("native HTTP client read deadline is configured");
stream
.set_write_timeout(Some(HTTP_OPERATION_BOUND))
.expect("native HTTP client write deadline is configured");
let tool_name_header =
tool_name.map_or_else(String::new, |name| format!("Mcp-Name: {name}\r\n"));
let request = format!(
"POST /mcp HTTP/1.1\r\nHost: {address}\r\nAuthorization: Bearer alpha\r\nAccept: application/json\r\nContent-Type: application/json\r\nMCP-Protocol-Version: {protocol_version}\r\nMcp-Method: {method}\r\n{tool_name_header}Content-Length: {}\r\n\r\n",
body.len(),
);
stream
.write_all(request.as_bytes())
.and_then(|()| stream.write_all(body))
.expect("native HTTP request commits to the public facade listener");
let mut response = Vec::new();
let mut buffer = [0_u8; 8 * 1024];
loop {
let read = stream
.read(&mut buffer)
.expect("native HTTP response reads within its configured deadline");
if read == 0 {
break;
}
assert!(
response
.len()
.checked_add(read)
.is_some_and(|size| size <= MAX_NATIVE_HTTP_RESPONSE_BYTES),
"native HTTP response exceeds the test's bounded response budget"
);
response.extend_from_slice(&buffer[..read]);
}
response
}
fn chunked_response_body(body: &[u8]) -> Vec<u8> {
let mut cursor = 0;
let mut decoded = Vec::new();
loop {
let size_end = body[cursor..]
.windows(2)
.position(|window| window == b"\r\n")
.map(|offset| cursor + offset)
.expect("chunked response contains a complete chunk-size line");
let size_line = std::str::from_utf8(&body[cursor..size_end])
.expect("chunked response chunk size is ASCII");
let size = usize::from_str_radix(size_line.split(';').next().unwrap_or_default(), 16)
.expect("chunked response chunk size is hexadecimal");
cursor = size_end + 2;
if size == 0 {
loop {
let trailer_end = body[cursor..]
.windows(2)
.position(|window| window == b"\r\n")
.map(|offset| cursor + offset)
.expect("chunked response contains a complete trailer line");
let trailer = &body[cursor..trailer_end];
cursor = trailer_end + 2;
if trailer.is_empty() {
assert_eq!(
cursor,
body.len(),
"chunked response has no bytes after its terminal trailer"
);
return decoded;
}
assert!(
trailer.contains(&b':'),
"chunked response trailer has an HTTP field delimiter"
);
}
}
let chunk_end = cursor
.checked_add(size)
.expect("chunked response chunk length does not overflow");
assert!(
chunk_end.checked_add(2).is_some_and(|terminator_end| {
terminator_end <= body.len() && &body[chunk_end..terminator_end] == b"\r\n"
}),
"chunked response contains a complete chunk body and terminator"
);
decoded.extend_from_slice(&body[cursor..chunk_end]);
cursor = chunk_end + 2;
}
}
fn response_body(response: &[u8]) -> Vec<u8> {
let header_end = response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("native HTTP response contains a complete header terminator");
let headers = std::str::from_utf8(&response[..header_end])
.expect("native HTTP response headers are ASCII");
let mut content_length = None;
let mut chunked = false;
for header in headers.lines().skip(1) {
let (name, value) = header
.split_once(':')
.expect("native HTTP response header has a field delimiter");
if name.eq_ignore_ascii_case("content-length") {
assert!(
content_length.is_none(),
"native HTTP response has one Content-Length field"
);
content_length = Some(
value
.trim()
.parse::<usize>()
.expect("native HTTP Content-Length is a valid byte count"),
);
}
if name.eq_ignore_ascii_case("transfer-encoding")
&& value
.split(',')
.any(|coding| coding.trim().eq_ignore_ascii_case("chunked"))
{
chunked = true;
}
}
let body = &response[header_end + 4..];
if chunked {
assert!(
content_length.is_none(),
"chunked native HTTP response does not also carry Content-Length"
);
return chunked_response_body(body);
}
let content_length =
content_length.expect("native HTTP response uses Content-Length or chunked framing");
assert_eq!(
body.len(),
content_length,
"native HTTP response body is complete according to Content-Length"
);
body.to_vec()
}
let server = spawn_modern_facade_http_server(true, None, false);
let discovery = JsonRpcRequest::new(
"server/discover",
Some(json!({
"_meta": {
"io.modelcontextprotocol/protocolVersion": modern::PROTOCOL_VERSION,
"io.modelcontextprotocol/clientCapabilities": {},
},
})),
1_i64,
);
let discovery_body =
serde_json::to_vec(&discovery).expect("exact-modern discovery request serializes");
let discovery_response = exchange(
server.address(),
modern::PROTOCOL_VERSION,
"server/discover",
None,
&discovery_body,
);
assert!(
discovery_response.starts_with(b"HTTP/1.1 200"),
"authenticated exact-modern discovery must succeed: {}",
String::from_utf8_lossy(&discovery_response)
);
let discovery_body = response_body(&discovery_response);
let discovery: serde_json::Value = serde_json::from_slice(&discovery_body)
.expect("authenticated discovery response is JSON-RPC");
assert_eq!(discovery["id"], json!(1));
assert!(
discovery.get("error").is_none(),
"authenticated discovery must negotiate the modern era: {discovery}"
);
assert_eq!(discovery["result"]["resultType"], json!("complete"));
assert_eq!(
discovery["result"]["supportedVersions"],
json!([modern::PROTOCOL_VERSION]),
"the public server advertises only its exact final wire version"
);
assert_eq!(
discovery["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"],
json!("facade-native-http-auth"),
"final server identity belongs in result metadata, never a legacy initialize field"
);
assert_eq!(
discovery["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["version"],
json!("1.0.0")
);
assert_eq!(discovery["result"]["capabilities"]["tools"], json!({}));
assert_eq!(discovery["result"]["capabilities"]["prompts"], json!({}));
assert_eq!(
discovery["result"]["capabilities"]["completions"],
json!({})
);
assert_eq!(discovery["result"]["cacheScope"], json!("private"));
assert!(
discovery["result"]["ttlMs"].is_u64(),
"final discovery exposes a nonnegative cache lifetime"
);
let tool = JsonRpcRequest::new(
"tools/call",
Some(json!({
"name": PUBLIC_HTTP_TOOL_NAME,
"arguments": {"value": PUBLIC_HTTP_TOOL_ARGUMENT},
"_meta": {
"io.modelcontextprotocol/protocolVersion": modern::PROTOCOL_VERSION,
"io.modelcontextprotocol/clientCapabilities": {},
},
})),
2_i64,
);
let tool_body = serde_json::to_vec(&tool).expect("exact-modern tool request serializes");
let tool_response = exchange(
server.address(),
modern::PROTOCOL_VERSION,
"tools/call",
Some(PUBLIC_HTTP_TOOL_NAME),
&tool_body,
);
assert!(
tool_response.starts_with(b"HTTP/1.1 200"),
"authenticated exact-modern tool call must succeed: {}",
String::from_utf8_lossy(&tool_response)
);
let tool_response_body = response_body(&tool_response);
let tool: serde_json::Value = serde_json::from_slice(&tool_response_body)
.expect("authenticated tool response is JSON-RPC");
assert_eq!(tool["id"], json!(2));
assert!(
tool.get("error").is_none(),
"authenticated exact-modern tool call must reach the handler: {tool}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"the authenticated exact-modern tool call invokes the facade handler once"
);
// RH-5 near-negative: retain the valid bearer and exact JSON-RPC body,
// changing only the native MCP-Protocol-Version header to an unsupported
// date. Strict admission must reject before the handler can run again.
let rejection = exchange(
server.address(),
"2025-11-25",
"tools/call",
Some(PUBLIC_HTTP_TOOL_NAME),
&tool_body,
);
assert!(
rejection.starts_with(b"HTTP/1.1 400"),
"the one-variable unsupported-era request must fail before dispatch: {}",
String::from_utf8_lossy(&rejection)
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"changing only MCP-Protocol-Version must not invoke the authenticated handler"
);
server.shutdown();
}
#[test]
fn e2e_public_http_final_cursor_query_kind_and_cache_identity_are_live_and_fail_closed() {
fn tools_page(result: CoreResult) -> fastmcp_rust::FinalListToolsResult {
let CoreResult::Final(FinalCoreResult::ToolsList { result, .. }) = result else {
panic!("tools/list must retain its exact final result vocabulary");
};
result.payload
}
fn typed_page_identity(page: &fastmcp_rust::FinalListToolsResult) -> serde_json::Value {
serde_json::to_value(page).expect("a typed final tools page retains an exact JSON identity")
}
let cx = Cx::for_request();
let server = HttpServerFixture::spawn_modern_with_page_size(1);
let admitted_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-final-cursor-cache-admission-client", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the public typed HTTP client admits the live final discovery response");
let discovery = admitted_client.server_discovery();
assert_eq!(discovery.result_type(), "complete");
assert_eq!(discovery.supported_versions().len(), 1);
assert_eq!(
discovery.supported_versions().first().map(String::as_str),
Some(modern::PROTOCOL_VERSION),
"typed HTTP admission retains the exact live final discovery version"
);
assert_eq!(
discovery
.server_info()
.expect("typed HTTP admission retains the discovered server identity")
.name,
"facade-native-http-auth"
);
drop(admitted_client);
let mut client = runtime_block_on_bounded(
&cx,
auto::client_builder()
.client_info("e2e-final-cursor-cache-client", "1.0.0")
.protocol_plan(plan(server.address(), ProtocolPolicy::ModernOnly))
.connect_http_client_with_cx(&cx),
)
.expect("the public HTTP client completes live modern discovery");
let first = tools_page(
runtime_block_on_bounded(
&cx,
client.request_final_core(&cx, "tools/list", json!({ "includeTags": ["cursor"] })),
)
.expect("the filtered first public tools page is admitted"),
);
assert_eq!(
first.tools.len(),
1,
"page size creates a real continuation"
);
assert_eq!(
first.ttl_ms,
CacheTtl::milliseconds(5 * 60 * 1_000),
"the typed first page retains its exact final ttlMs"
);
assert_eq!(
first.cache_scope,
CacheScope::Private,
"the typed first page retains its exact final cacheScope"
);
let first_identity = typed_page_identity(&first);
let cursor = first
.next_cursor
.clone()
.expect("the first filtered page carries an opaque cursor");
assert_eq!(client.final_result_cache_stats().fills, 1);
assert_eq!(client.final_result_cache_stats().hits, 0);
let replay = tools_page(
runtime_block_on_bounded(
&cx,
client.request_final_core(&cx, "tools/list", json!({ "includeTags": ["cursor"] })),
)
.expect("the identical public list request uses its local final cache"),
);
assert_eq!(
typed_page_identity(&replay),
first_identity,
"the cache replay retains the complete typed first-page identity"
);
assert_eq!(client.final_result_cache_stats().fills, 1);
assert_eq!(
client.final_result_cache_stats().hits,
1,
"an identical final request is a public cache hit"
);
let second = tools_page(
runtime_block_on_bounded(
&cx,
client.request_final_core(
&cx,
"tools/list",
json!({ "cursor": cursor.clone(), "includeTags": ["cursor"] }),
),
)
.expect("the exact cursor/query continuation reaches the second live page"),
);
assert_eq!(second.tools.len(), 1);
assert_eq!(
second.tools[0].name, PUBLIC_HTTP_CURSOR_SECONDARY_TOOL_NAME,
"the sole valid continuation retains the exact cursor-secondary tool identity"
);
assert!(second.next_cursor.is_none());
assert_eq!(
second.ttl_ms,
CacheTtl::milliseconds(5 * 60 * 1_000),
"the typed continuation page retains its exact final ttlMs"
);
assert_eq!(
second.cache_scope,
CacheScope::Private,
"the typed continuation page retains its exact final cacheScope"
);
assert_ne!(
typed_page_identity(&second),
first_identity,
"the continuation must retain a complete typed page identity distinct from page one"
);
// RH-5: only the query changes. The retained cursor must not cross into a
// different filtered catalog, and the prior cached first page stays live.
let query_rejection = runtime_block_on_bounded(
&cx,
client.request_final_core(
&cx,
"tools/list",
json!({ "cursor": cursor.clone(), "includeTags": ["other"] }),
),
)
.expect_err("changing only includeTags rejects the cursor before page projection");
assert!(matches!(
query_rejection,
fastmcp_rust::HttpClientError::CoreResult(error)
if error.code == McpErrorCode::InvalidParams
));
// RH-5: only the catalog method changes. A tools cursor cannot select a
// resources page, and this rejection must not flush the cached tools page.
let kind_rejection = runtime_block_on_bounded(
&cx,
client.request_final_core(
&cx,
"resources/list",
json!({ "cursor": cursor.clone(), "includeTags": ["cursor"] }),
),
)
.expect_err("changing only the catalog kind rejects the tools cursor");
assert!(matches!(
kind_rejection,
fastmcp_rust::HttpClientError::CoreResult(error)
if error.code == McpErrorCode::InvalidParams
));
// RH-5: only wire presence changes from an absent cursor to the explicitly
// present empty cursor. The cache must not replay the absent-cursor page.
let empty_cursor_rejection = runtime_block_on_bounded(
&cx,
client.request_final_core(
&cx,
"tools/list",
json!({ "cursor": "", "includeTags": ["cursor"] }),
),
)
.expect_err("an explicit empty cursor is not the absent-cursor cache identity");
assert!(matches!(
empty_cursor_rejection,
fastmcp_rust::HttpClientError::CoreResult(error)
if error.code == McpErrorCode::InvalidParams
));
let before_unchanged_retry = client.final_result_cache_stats();
let unchanged_retry = tools_page(
runtime_block_on_bounded(
&cx,
client.request_final_core(&cx, "tools/list", json!({ "includeTags": ["cursor"] })),
)
.expect("cursor rejections leave the already-cached first page unchanged"),
);
assert_eq!(
typed_page_identity(&unchanged_retry),
first_identity,
"cursor rejections leave the complete cached typed first-page identity unchanged"
);
assert_eq!(
client.final_result_cache_stats().fills,
before_unchanged_retry.fills,
"rejected cursor variations cannot overwrite the admitted cached page"
);
assert_eq!(
client.final_result_cache_stats().hits,
before_unchanged_retry.hits + 1,
"the unchanged original request still resolves from its prior cache entry"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_modern_list_tools_include_and_exclude_tags() {
let cx = Cx::for_request();
let server = HttpServerFixture::spawn_with_policy(ProtocolPolicy::ModernOnly);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-modern-tag-filter", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the tagged HTTP peer");
let cursor = runtime_block_on_bounded(
&cx,
client.list_tools_with_params(
&cx,
ListToolsParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListToolsParams::default()
},
),
)
.expect("includeTags cursor must list the cursor-tagged HTTP tools");
assert!(
cursor
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME),
"includeTags cursor must retain {PUBLIC_HTTP_TOOL_NAME}: {cursor:?}"
);
assert!(
cursor
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_CURSOR_SECONDARY_TOOL_NAME),
"includeTags cursor must retain {PUBLIC_HTTP_CURSOR_SECONDARY_TOOL_NAME}: {cursor:?}"
);
assert!(
cursor
.tools
.iter()
.all(|tool| tool.name != PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME),
"includeTags cursor must omit {PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME}: {cursor:?}"
);
let other = runtime_block_on_bounded(
&cx,
client.list_tools_with_params(
&cx,
ListToolsParams {
include_tags: Some(vec!["other".to_owned()]),
..ListToolsParams::default()
},
),
)
.expect("changing only includeTags must list the other-tagged HTTP tool");
assert!(
other
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME),
"includeTags other must retain {PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME}: {other:?}"
);
assert!(
other
.tools
.iter()
.all(|tool| tool.name != PUBLIC_HTTP_TOOL_NAME
&& tool.name != PUBLIC_HTTP_CURSOR_SECONDARY_TOOL_NAME),
"includeTags other must omit the cursor-tagged tools: {other:?}"
);
let excluded = runtime_block_on_bounded(
&cx,
client.list_tools_with_params(
&cx,
ListToolsParams {
exclude_tags: Some(vec!["cursor".to_owned()]),
..ListToolsParams::default()
},
),
)
.expect("excludeTags cursor must omit only the cursor-tagged HTTP tools");
assert!(
excluded
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME),
"excludeTags cursor must keep {PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME}: {excluded:?}"
);
assert!(
excluded
.tools
.iter()
.all(|tool| tool.name != PUBLIC_HTTP_TOOL_NAME
&& tool.name != PUBLIC_HTTP_CURSOR_SECONDARY_TOOL_NAME),
"excludeTags cursor must omit the cursor-tagged tools: {excluded:?}"
);
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("an unfiltered modern HTTP tools/list must keep both tag groups");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME)
&& listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME),
"omitting only the tag filters must list cursor and other tools: {listed:?}"
);
drop(client);
server.shutdown();
}
const PUBLIC_HTTP_CURSOR_TEMPLATE: &str = "test://public-http-e2e/tmpl-cursor/{id}";
const PUBLIC_HTTP_OTHER_TEMPLATE: &str = "test://public-http-e2e/tmpl-other/{id}";
#[test]
fn e2e_public_http_modern_list_resources_prompts_and_templates_include_and_exclude_tags() {
let cx = Cx::for_request();
let server = HttpServerFixture::spawn_modern_tagged_catalog();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-modern-catalog-tag-filter", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the tagged HTTP catalog");
let cursor_resources = runtime_block_on_bounded(
&cx,
client.list_resources_with_params(
&cx,
ListResourcesParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListResourcesParams::default()
},
),
)
.expect("includeTags cursor must list the cursor-tagged HTTP resource");
assert!(
cursor_resources
.resources
.iter()
.any(|resource| resource.uri.as_str() == PUBLIC_HTTP_CURSOR_RESOURCE_A_URI),
"includeTags cursor must retain {PUBLIC_HTTP_CURSOR_RESOURCE_A_URI}: {cursor_resources:?}"
);
assert!(
cursor_resources
.resources
.iter()
.all(|resource| resource.uri.as_str() != PUBLIC_HTTP_RESOURCE_URI),
"includeTags cursor must omit {PUBLIC_HTTP_RESOURCE_URI}: {cursor_resources:?}"
);
let excluded_resources = runtime_block_on_bounded(
&cx,
client.list_resources_with_params(
&cx,
ListResourcesParams {
exclude_tags: Some(vec!["cursor".to_owned()]),
..ListResourcesParams::default()
},
),
)
.expect("excludeTags cursor must omit only the cursor-tagged HTTP resource");
assert!(
excluded_resources
.resources
.iter()
.any(|resource| resource.uri.as_str() == PUBLIC_HTTP_RESOURCE_URI),
"excludeTags cursor must keep {PUBLIC_HTTP_RESOURCE_URI}: {excluded_resources:?}"
);
assert!(
excluded_resources
.resources
.iter()
.all(|resource| resource.uri.as_str() != PUBLIC_HTTP_CURSOR_RESOURCE_A_URI),
"excludeTags cursor must omit {PUBLIC_HTTP_CURSOR_RESOURCE_A_URI}: {excluded_resources:?}"
);
let cursor_prompts = runtime_block_on_bounded(
&cx,
client.list_prompts_with_params(
&cx,
ListPromptsParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListPromptsParams::default()
},
),
)
.expect("includeTags cursor must list the cursor-tagged HTTP prompt");
assert!(
cursor_prompts
.prompts
.iter()
.any(|prompt| prompt.name == PUBLIC_HTTP_CURSOR_PROMPT_A_NAME),
"includeTags cursor must retain {PUBLIC_HTTP_CURSOR_PROMPT_A_NAME}: {cursor_prompts:?}"
);
assert!(
cursor_prompts
.prompts
.iter()
.all(|prompt| prompt.name != PUBLIC_HTTP_PROMPT_NAME),
"includeTags cursor must omit {PUBLIC_HTTP_PROMPT_NAME}: {cursor_prompts:?}"
);
let excluded_prompts = runtime_block_on_bounded(
&cx,
client.list_prompts_with_params(
&cx,
ListPromptsParams {
exclude_tags: Some(vec!["cursor".to_owned()]),
..ListPromptsParams::default()
},
),
)
.expect("excludeTags cursor must omit only the cursor-tagged HTTP prompt");
assert!(
excluded_prompts
.prompts
.iter()
.any(|prompt| prompt.name == PUBLIC_HTTP_PROMPT_NAME),
"excludeTags cursor must keep {PUBLIC_HTTP_PROMPT_NAME}: {excluded_prompts:?}"
);
assert!(
excluded_prompts
.prompts
.iter()
.all(|prompt| prompt.name != PUBLIC_HTTP_CURSOR_PROMPT_A_NAME),
"excludeTags cursor must omit {PUBLIC_HTTP_CURSOR_PROMPT_A_NAME}: {excluded_prompts:?}"
);
let cursor_templates = runtime_block_on_bounded(
&cx,
client.list_resource_templates_with_params(
&cx,
ListResourceTemplatesParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListResourceTemplatesParams::default()
},
),
)
.expect("includeTags cursor must list the cursor-tagged HTTP template");
assert!(
cursor_templates
.resource_templates
.iter()
.any(|template| template.uri_template == PUBLIC_HTTP_CURSOR_TEMPLATE),
"includeTags cursor must retain {PUBLIC_HTTP_CURSOR_TEMPLATE}: {cursor_templates:?}"
);
assert!(
cursor_templates
.resource_templates
.iter()
.all(|template| template.uri_template != PUBLIC_HTTP_OTHER_TEMPLATE),
"includeTags cursor must omit {PUBLIC_HTTP_OTHER_TEMPLATE}: {cursor_templates:?}"
);
let other_templates = runtime_block_on_bounded(
&cx,
client.list_resource_templates_with_params(
&cx,
ListResourceTemplatesParams {
include_tags: Some(vec!["other".to_owned()]),
..ListResourceTemplatesParams::default()
},
),
)
.expect("changing only includeTags must list the other-tagged HTTP template");
assert!(
other_templates
.resource_templates
.iter()
.any(|template| template.uri_template == PUBLIC_HTTP_OTHER_TEMPLATE),
"includeTags other must retain {PUBLIC_HTTP_OTHER_TEMPLATE}: {other_templates:?}"
);
assert!(
other_templates
.resource_templates
.iter()
.all(|template| template.uri_template != PUBLIC_HTTP_CURSOR_TEMPLATE),
"includeTags other must omit {PUBLIC_HTTP_CURSOR_TEMPLATE}: {other_templates:?}"
);
let live = McpRequestCancellation::new();
let cancelled_filter = runtime_block_on_bounded(
&cx,
client.list_tools_with_params_and_cancellation(
&cx,
&live,
ListToolsParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListToolsParams::default()
},
),
)
.expect("an uncancelled domain must still send includeTags on tools/list");
assert!(
cancelled_filter
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME),
"live cancellation domain plus includeTags cursor must retain {PUBLIC_HTTP_TOOL_NAME}: {cancelled_filter:?}"
);
assert!(
cancelled_filter
.tools
.iter()
.all(|tool| tool.name != PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME),
"live cancellation domain plus includeTags cursor must omit {PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME}: {cancelled_filter:?}"
);
let already = McpRequestCancellation::new();
already.cancel();
let pre_send = runtime_block_on_bounded(
&cx,
client.list_tools_with_params_and_cancellation(
&cx,
&already,
ListToolsParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListToolsParams::default()
},
),
)
.expect_err("pre-send cancellation must reject a tagged tools/list locally");
assert!(matches!(
pre_send,
modern::HttpClientError::CoreResult(error)
if error.code == McpErrorCode::RequestCancelled
));
let live_resources = McpRequestCancellation::new();
let cancelled_resources = runtime_block_on_bounded(
&cx,
client.list_resources_with_params_and_cancellation(
&cx,
&live_resources,
ListResourcesParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListResourcesParams::default()
},
),
)
.expect("an uncancelled domain must still send includeTags on resources/list");
assert!(
cancelled_resources
.resources
.iter()
.any(|resource| resource.uri.as_str() == PUBLIC_HTTP_CURSOR_RESOURCE_A_URI),
"live cancellation domain plus includeTags cursor must retain {PUBLIC_HTTP_CURSOR_RESOURCE_A_URI}: {cancelled_resources:?}"
);
assert!(
cancelled_resources
.resources
.iter()
.all(|resource| resource.uri.as_str() != PUBLIC_HTTP_RESOURCE_URI),
"live cancellation domain plus includeTags cursor must omit {PUBLIC_HTTP_RESOURCE_URI}: {cancelled_resources:?}"
);
let live_prompts = McpRequestCancellation::new();
let cancelled_prompts = runtime_block_on_bounded(
&cx,
client.list_prompts_with_params_and_cancellation(
&cx,
&live_prompts,
ListPromptsParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListPromptsParams::default()
},
),
)
.expect("an uncancelled domain must still send includeTags on prompts/list");
assert!(
cancelled_prompts
.prompts
.iter()
.any(|prompt| prompt.name == PUBLIC_HTTP_CURSOR_PROMPT_A_NAME),
"live cancellation domain plus includeTags cursor must retain {PUBLIC_HTTP_CURSOR_PROMPT_A_NAME}: {cancelled_prompts:?}"
);
assert!(
cancelled_prompts
.prompts
.iter()
.all(|prompt| prompt.name != PUBLIC_HTTP_PROMPT_NAME),
"live cancellation domain plus includeTags cursor must omit {PUBLIC_HTTP_PROMPT_NAME}: {cancelled_prompts:?}"
);
let live_templates = McpRequestCancellation::new();
let cancelled_templates = runtime_block_on_bounded(
&cx,
client.list_resource_templates_with_params_and_cancellation(
&cx,
&live_templates,
ListResourceTemplatesParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListResourceTemplatesParams::default()
},
),
)
.expect("an uncancelled domain must still send includeTags on resources/templates/list");
assert!(
cancelled_templates
.resource_templates
.iter()
.any(|template| template.uri_template == PUBLIC_HTTP_CURSOR_TEMPLATE),
"live cancellation domain plus includeTags cursor must retain {PUBLIC_HTTP_CURSOR_TEMPLATE}: {cancelled_templates:?}"
);
assert!(
cancelled_templates
.resource_templates
.iter()
.all(|template| template.uri_template != PUBLIC_HTTP_OTHER_TEMPLATE),
"live cancellation domain plus includeTags cursor must omit {PUBLIC_HTTP_OTHER_TEMPLATE}: {cancelled_templates:?}"
);
let already_catalog = McpRequestCancellation::new();
already_catalog.cancel();
let pre_send_resources = runtime_block_on_bounded(
&cx,
client.list_resources_with_params_and_cancellation(
&cx,
&already_catalog,
ListResourcesParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListResourcesParams::default()
},
),
)
.expect_err("pre-send cancellation must reject a tagged resources/list locally");
assert!(matches!(
pre_send_resources,
modern::HttpClientError::CoreResult(error)
if error.code == McpErrorCode::RequestCancelled
));
let pre_send_prompts = runtime_block_on_bounded(
&cx,
client.list_prompts_with_params_and_cancellation(
&cx,
&already_catalog,
ListPromptsParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListPromptsParams::default()
},
),
)
.expect_err("pre-send cancellation must reject a tagged prompts/list locally");
assert!(matches!(
pre_send_prompts,
modern::HttpClientError::CoreResult(error)
if error.code == McpErrorCode::RequestCancelled
));
let pre_send_templates = runtime_block_on_bounded(
&cx,
client.list_resource_templates_with_params_and_cancellation(
&cx,
&already_catalog,
ListResourceTemplatesParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListResourceTemplatesParams::default()
},
),
)
.expect_err("pre-send cancellation must reject a tagged resources/templates/list locally");
assert!(matches!(
pre_send_templates,
modern::HttpClientError::CoreResult(error)
if error.code == McpErrorCode::RequestCancelled
));
drop(client);
server.shutdown();
}
#[test]
fn e2e_modern_facade_native_http_completion_returns_typed_result_and_rejects_undeclared_argument() {
let cx = Cx::for_request();
let server = spawn_modern_facade_http_server(false, None, false);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-modern-completion-client", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the live completion provider");
let params = modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: PUBLIC_HTTP_PROMPT_NAME.to_owned(),
title: "Public HTTP E2E Prompt".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
context: Some(modern::FinalCompletionContext {
arguments: Some(std::collections::BTreeMap::from([(
"region".to_owned(),
"us-east-1".to_owned(),
)])),
}),
};
runtime_block_on_bounded(&cx, client.complete(&cx, params.clone()))
.expect("a completion/complete without a progress token still completes");
assert!(
client.take_progress_notifications().is_empty(),
"without a progressToken the completion handler must not emit request-scoped progress"
);
let marker = modern::ProgressMarker::from("http-completion-progress");
let result = runtime_block_on_bounded(
&cx,
client.complete_with_progress_marker(&cx, params.clone(), marker.clone()),
)
.expect("the typed modern HTTP completion reaches the live facade provider");
assert_eq!(
result.completion.values,
vec![PUBLIC_HTTP_COMPLETION_VALUE.to_owned()],
"the exact FinalCompletionResult retains the provider values"
);
assert_eq!(
result.completion.total,
Some(modern::JsonInteger::from(1_i64)),
"the exact FinalCompletionResult retains its JSON-integer total"
);
assert_eq!(
result.completion.has_more,
Some(false),
"the exact FinalCompletionResult retains the terminal pagination flag"
);
let progress = client.take_progress_notifications();
assert!(
progress.iter().any(|notification| {
notification.progress_token == marker
&& notification.message.as_deref() == Some("completion-halfway")
}),
"live bind_http must retain completion notifications/progress after a progressToken: {progress:?}"
);
assert_eq!(
server.handler_call_snapshot().completion,
2,
"the accepted typed completion invokes the registered provider once per successful request"
);
// RH-5 near-negative: retain the target, title, completion context, and
// prefix, changing only the completion argument name. Router validation
// must reject before the registered provider can run again.
let mut undeclared_argument = params;
undeclared_argument.argument.name = "undeclared".to_owned();
let error = runtime_block_on_bounded(&cx, client.complete(&cx, undeclared_argument))
.expect_err("only an undeclared completion argument is rejected");
assert!(matches!(
error,
modern::HttpClientError::CoreResult(error) if error.code == McpErrorCode::InvalidParams
));
assert_eq!(
server.handler_call_snapshot().completion,
2,
"changing only the argument name leaves provider state unchanged"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_cross_era_refusals_leave_handler_observables_unchanged() {
let cx = Cx::for_request();
let modern_server = HttpServerFixture::spawn_with_policy(ProtocolPolicy::ModernOnly);
let modern_before = invoke_modern_public_handlers(&cx, modern_server.address());
let modern_calls_before_refusal = modern_server.handler_call_snapshot();
// Keep the legacy facade configuration identical to its matched-era
// control; the endpoint's live server policy is the only intentional
// protocol-selection variable.
let legacy_refusal = match runtime_block_on_bounded(
&cx,
legacy_2024::http_client_builder(
public_http_target(modern_server.address(), "/sse"),
public_http_target(modern_server.address(), "/messages"),
)
.expect("the exact legacy HTTP endpoints form one public facade plan")
.client_info("e2e-public-legacy-handler-client", "1.0.0")
.connect_http_client_with_cx(&cx),
) {
Err(error) => error,
Ok(_) => {
panic!(
"a LegacyOnly public facade must reject the ModernOnly server's live SSE refusal"
)
}
};
assert!(matches!(
legacy_refusal,
legacy_2024::HttpClientError::Connection(ClientHttpConnectionError::Modern(
fastmcp_rust::ModernHttpClientError::LegacySse(
fastmcp_rust::LegacySseHttpClientError::SseGetRejected { status: 400 }
)
))
));
assert_eq!(
modern_server.handler_call_snapshot(),
modern_calls_before_refusal,
"the refused exact-legacy connection must not invoke a ModernOnly handler"
);
let modern_after = invoke_modern_public_handlers(&cx, modern_server.address());
assert_eq!(
modern_after, modern_before,
"the refused exact-legacy connection must not alter later ModernOnly handler observables"
);
modern_server.shutdown();
let legacy_server = HttpServerFixture::spawn_with_policy(ProtocolPolicy::LegacyOnly);
let legacy_before = invoke_legacy_public_handlers(&cx, legacy_server.address());
let legacy_calls_before_refusal = legacy_server.handler_call_snapshot();
// Keep the modern facade configuration identical to its matched-era
// control; the endpoint's live server policy is the only intentional
// protocol-selection variable.
let modern_refusal = match runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-modern-handler-client", "1.0.0")
.connect_http_with_cx(public_http_target(legacy_server.address(), "/mcp"), &cx),
) {
Err(error) => error,
Ok(_) => {
panic!(
"a ModernOnly public facade must reject the LegacyOnly server's live probe refusal"
)
}
};
assert!(matches!(
modern_refusal,
modern::HttpClientConnectError::Connect(
modern::HttpClientError::Connection(ClientHttpConnectionError::Modern(
fastmcp_rust::ModernHttpClientError::Negotiation(
fastmcp_rust::ClientHttpNegotiationError::ModernProbeRejectedWithoutLegacyFallback {
status: 400,
..
}
)
))
)
));
assert_eq!(
legacy_server.handler_call_snapshot(),
legacy_calls_before_refusal,
"the refused modern connection must not invoke a LegacyOnly handler"
);
let legacy_after = invoke_legacy_public_handlers(&cx, legacy_server.address());
assert_eq!(
legacy_after, legacy_before,
"the refused modern connection must not alter later LegacyOnly handler observables"
);
legacy_server.shutdown();
}
#[test]
fn e2e_public_http_auto_selects_modern_on_the_shipped_facade_server() {
let server = HttpServerFixture::spawn();
let cx = Cx::for_request();
let builder = auto::client_builder()
.client_info("e2e-modern-http-client", "1.0.0")
.protocol_plan(plan(server.address(), ProtocolPolicy::Auto));
assert_eq!(
builder.selected_protocol_plan().policy(),
ProtocolPolicy::Auto
);
let mut client = runtime_block_on_bounded(&cx, builder.connect_http_client_with_cx(&cx))
.expect("the public Auto facade client completes live modern discovery");
assert_eq!(
client.selected_protocol_era(),
ProtocolEra::Modern2026,
"a live modern discovery response must never downgrade Auto"
);
assert_eq!(client.protocol_plan().policy(), ProtocolPolicy::Auto);
assert!(
client.server_discovery().is_some(),
"modern selection exposes the public discovery result"
);
assert_eq!(
client.connection().protocol_version(),
Some(fastmcp_rust::modern::PROTOCOL_VERSION),
"the public HTTP connection reports the exact modern negotiated version"
);
let response = runtime_block_on_bounded(
&cx,
client.request(&cx, "tools/list", json!({ "cursor": null })),
)
.expect("the Auto-selected modern connection serves requests");
let ClientHttpResponse::Modern(response) = response else {
panic!("the selected modern HTTP client must retain the modern response lane");
};
let document = final_response_document(&cx, response);
assert_eq!(document["id"], json!(2));
assert!(
document.get("error").is_none(),
"tools/list over the Auto-selected connection must not fail: {document}"
);
assert!(
document["result"]["tools"].is_array(),
"the modern tools/list response must carry its tools result: {document}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_modern_only_selects_modern_and_refuses_legacy_only() {
let modern_server = HttpServerFixture::spawn_with_policy(ProtocolPolicy::ModernOnly);
let cx = Cx::for_request();
let builder = auto::client_builder()
.client_info("e2e-modern-only-http-client", "1.0.0")
.protocol_plan(plan(modern_server.address(), ProtocolPolicy::ModernOnly));
let mut client = runtime_block_on_bounded(&cx, builder.connect_http_client_with_cx(&cx))
.expect("a ModernOnly facade plan selects the real ModernOnly endpoint");
assert_eq!(client.selected_protocol_era(), ProtocolEra::Modern2026);
assert_eq!(
client.connection().protocol_version(),
Some(fastmcp_rust::modern::PROTOCOL_VERSION),
"the positive ModernOnly connection reports the exact modern version"
);
assert!(
client.server_discovery().is_some(),
"the positive ModernOnly connection retains modern discovery"
);
let response = runtime_block_on_bounded(
&cx,
client.request(&cx, "tools/list", json!({ "cursor": null })),
)
.expect("the selected ModernOnly connection serves tools/list");
let ClientHttpResponse::Modern(response) = response else {
panic!("the ModernOnly connection must retain the modern response lane");
};
let document = final_response_document(&cx, response);
assert_eq!(document["id"], json!(2));
assert!(
document.get("error").is_none(),
"the positive ModernOnly tools/list response must not fail: {document}"
);
assert!(
document["result"]["tools"].is_array(),
"the positive ModernOnly tools/list response carries its result: {document}"
);
drop(client);
modern_server.shutdown();
// This differs from the positive case only in the live server policy.
// A ModernOnly plan has no configured legacy fallback and must not create
// a client after the LegacyOnly endpoint refuses its modern request.
let legacy_server = HttpServerFixture::spawn_with_policy(ProtocolPolicy::LegacyOnly);
let refusal = match runtime_block_on_bounded(
&cx,
auto::client_builder()
.client_info("e2e-modern-only-http-client", "1.0.0")
.protocol_plan(plan(legacy_server.address(), ProtocolPolicy::ModernOnly))
.connect_http_client_with_cx(&cx),
) {
Err(error) => error,
Ok(_) => panic!("a ModernOnly facade plan must reject the LegacyOnly endpoint's live 400"),
};
assert!(matches!(
refusal,
auto::HttpClientError::Connection(auto::ClientHttpConnectionError::Modern(
fastmcp_rust::ModernHttpClientError::Negotiation(
auto::ClientHttpNegotiationError::ModernProbeRejectedWithoutLegacyFallback {
status: 400,
..
}
)
))
));
legacy_server.shutdown();
}
#[test]
fn e2e_public_http_legacy_only_selects_legacy_and_refuses_modern_only() {
let legacy_server = HttpServerFixture::spawn_with_policy(ProtocolPolicy::LegacyOnly);
let cx = Cx::for_request();
let builder = auto::client_builder()
.client_info("e2e-legacy-only-http-client", "1.0.0")
.protocol_plan(plan(legacy_server.address(), ProtocolPolicy::LegacyOnly));
let mut client = runtime_block_on_bounded(&cx, builder.connect_http_client_with_cx(&cx))
.expect("a LegacyOnly facade plan selects the real exact-legacy endpoint");
assert_eq!(client.selected_protocol_era(), ProtocolEra::Legacy2024);
assert_eq!(
client.connection().protocol_version(),
Some(fastmcp_rust::legacy_2024::PROTOCOL_VERSION),
"the positive LegacyOnly connection reports the exact 2024-11-05 version"
);
assert!(
client.server_discovery().is_none(),
"the positive LegacyOnly connection cannot retain modern discovery"
);
let response = runtime_block_on_bounded(&cx, client.request(&cx, "ping", json!({})))
.expect("the selected LegacyOnly connection serves ping");
let ClientHttpResponse::Legacy(JsonRpcMessage::Response(ping)) = response else {
panic!("the LegacyOnly connection must retain the exact-legacy response lane");
};
assert!(
ping.error.is_none(),
"the positive exact-legacy ping must not fail"
);
assert_eq!(ping.result, Some(json!({})));
drop(client);
legacy_server.shutdown();
// This differs from the positive case only in the live server policy.
// A LegacyOnly plan has no configured modern route and must not manufacture
// an exact-legacy session after the ModernOnly endpoint refuses its SSE GET.
let modern_server = HttpServerFixture::spawn_with_policy(ProtocolPolicy::ModernOnly);
let refusal = match runtime_block_on_bounded(
&cx,
auto::client_builder()
.client_info("e2e-legacy-only-http-client", "1.0.0")
.protocol_plan(plan(modern_server.address(), ProtocolPolicy::LegacyOnly))
.connect_http_client_with_cx(&cx),
) {
Err(error) => error,
Ok(_) => panic!("a LegacyOnly facade plan must reject the ModernOnly endpoint's live 400"),
};
assert!(matches!(
refusal,
auto::HttpClientError::Connection(auto::ClientHttpConnectionError::Modern(
fastmcp_rust::ModernHttpClientError::LegacySse(
fastmcp_rust::LegacySseHttpClientError::SseGetRejected { status: 400 }
)
))
));
modern_server.shutdown();
}
#[test]
fn e2e_public_http_auto_falls_back_to_exact_legacy_on_live_eligible_refusal() {
let server = HttpServerFixture::spawn_with_policy(ProtocolPolicy::LegacyOnly);
let cx = Cx::for_request();
// This differs from the matched Auto-modern positive only in the live
// server policy. The LegacyOnly endpoint returns its real modern-route
// refusal, after which Auto may open the configured exact-legacy routes.
let builder = auto::client_builder()
.client_info("e2e-modern-http-client", "1.0.0")
.protocol_plan(plan(server.address(), ProtocolPolicy::Auto));
assert_eq!(
builder.selected_protocol_plan().policy(),
ProtocolPolicy::Auto
);
let mut client = runtime_block_on_bounded(&cx, builder.connect_http_client_with_cx(&cx))
.expect("an eligible live modern-route refusal completes the exact legacy lifecycle");
assert_eq!(
client.selected_protocol_era(),
ProtocolEra::Legacy2024,
"Auto must expose exact legacy selection only after its eligible live refusal"
);
assert_eq!(client.protocol_plan().policy(), ProtocolPolicy::Auto);
assert!(
client.server_discovery().is_none(),
"exact legacy fallback cannot expose modern discovery state"
);
assert_eq!(
client.connection().protocol_version(),
Some(fastmcp_rust::legacy_2024::PROTOCOL_VERSION),
"the public HTTP connection retains the exact validated legacy initialize wire version"
);
assert_eq!(
client.server_info().name,
"facade-http-example",
"the legacy initialization result comes from the shipped facade server composition"
);
let ping = runtime_block_on_bounded(&cx, client.request(&cx, "ping", json!({})))
.expect("the initialized exact-legacy fallback remains usable");
let ClientHttpResponse::Legacy(JsonRpcMessage::Response(ping)) = ping else {
panic!("the exact-legacy fallback must answer ping through its legacy response lane");
};
assert!(ping.error.is_none(), "legacy ping must not fail: {ping:?}");
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_auto_isolates_live_modern_and_legacy_clients() {
let gate = Arc::new(OverlapFinalMethodGate::default());
let mut server =
HttpServerFixture::spawn_with_middleware(Some(Arc::clone(&gate) as Arc<dyn Middleware>));
let address = server.address();
let (completed_tx, completed_rx) = mpsc::channel();
let modern_completed_tx = completed_tx.clone();
let mut modern_worker = Some(thread::spawn(move || {
let result = (|| -> Result<serde_json::Value, String> {
let cx = Cx::for_request();
let builder = auto::client_builder()
.client_info("e2e-isolated-modern-client", "1.0.0")
.protocol_plan(plan(address, ProtocolPolicy::Auto));
let mut client =
runtime_block_on_bounded(&cx, builder.connect_http_client_with_cx(&cx)).map_err(
|error| format!("the first Auto client did not select modern: {error}"),
)?;
if client.selected_protocol_era() != ProtocolEra::Modern2026 {
return Err("the first client did not retain an independent modern era".to_owned());
}
let response = runtime_block_on_bounded(
&cx,
client.request(&cx, "tools/list", json!({ "cursor": null })),
)
.map_err(|error| format!("the modern tools/list request failed: {error}"))?;
let ClientHttpResponse::Modern(response) = response else {
return Err("the first client did not retain its modern response lane".to_owned());
};
Ok(final_response_document(&cx, response))
})();
let _ = modern_completed_tx.send(OverlapWorkerCompletion::Modern(result));
}));
let legacy_completed_tx = completed_tx.clone();
let mut legacy_worker = Some(thread::spawn(move || {
let result = (|| -> Result<serde_json::Value, String> {
let cx = Cx::for_request();
let builder = auto::client_builder()
.client_info("e2e-isolated-legacy-client", "1.0.0")
.protocol_plan(plan_with_modern_post_path(
address,
ProtocolPolicy::Auto,
"/modern-unavailable",
));
let mut client =
runtime_block_on_bounded(&cx, builder.connect_http_client_with_cx(&cx)).map_err(
|error| format!("the second Auto client did not select legacy: {error}"),
)?;
if client.selected_protocol_era() != ProtocolEra::Legacy2024 {
return Err("the second client inherited a foreign protocol era".to_owned());
}
let response = runtime_block_on_bounded(&cx, client.request(&cx, "ping", json!({})))
.map_err(|error| format!("the legacy ping request failed: {error}"))?;
let ClientHttpResponse::Legacy(JsonRpcMessage::Response(response)) = response else {
return Err("the second client did not retain its legacy response lane".to_owned());
};
serde_json::to_value(response)
.map_err(|error| format!("the legacy response did not serialize: {error}"))
})();
let _ = legacy_completed_tx.send(OverlapWorkerCompletion::Legacy(result));
}));
drop(completed_tx);
let admission = gate.wait_for_cross_era_requests();
gate.release();
let (modern_document, legacy_document) =
collect_overlap_worker_results(&completed_rx, HTTP_SERVER_TEARDOWN_BOUND);
let modern_join = join_finished_thread(
&mut modern_worker,
HTTP_SERVER_TEARDOWN_BOUND,
"modern overlap worker",
);
let legacy_join = join_finished_thread(
&mut legacy_worker,
HTTP_SERVER_TEARDOWN_BOUND,
"legacy overlap worker",
);
let teardown = server.settle();
admission.expect(
"the modern tools/list and legacy ping requests must reach the server before either response runs",
);
teardown.expect("the bounded server teardown runs after overlap completion collection");
modern_join.expect("the modern overlap worker joins without detaching");
legacy_join.expect("the legacy overlap worker joins without detaching");
let modern_document = modern_document.expect("the overlapping modern request completes");
assert_eq!(modern_document["id"], json!(2));
assert!(
modern_document.get("error").is_none(),
"the isolated modern tools/list request must not fail: {modern_document}"
);
assert!(
modern_document["result"]["tools"].is_array(),
"the isolated modern tools/list response must carry its tools result: {modern_document}"
);
let legacy_document = legacy_document.expect("the overlapping legacy request completes");
assert_eq!(
legacy_document["id"],
json!(2),
"both clients reuse their local final-request id without cross-client correlation"
);
assert!(
legacy_document.get("error").is_none(),
"the isolated legacy ping must not fail: {legacy_document}"
);
assert_eq!(
legacy_document["result"],
json!({}),
"the isolated legacy response must remain the ping acknowledgement: {legacy_document}"
);
}
const PUBLIC_HTTP_SAMPLING_TOOL_NAME: &str = "public-http-e2e-sampling";
const PUBLIC_HTTP_ROOTS_TOOL_NAME: &str = "public-http-e2e-roots";
const PUBLIC_HTTP_URL_ELICITATION_TOOL_NAME: &str = "public-http-e2e-url-elicitation";
const PUBLIC_HTTP_ELICITATION_RESOURCE_URI: &str = "test://public-http-e2e/elicitation";
const PUBLIC_HTTP_ELICITATION_PROMPT_NAME: &str = "public-http-e2e-elicitation-prompt";
/// Live modern HTTP tool that returns framework-issued MRTR sampling input.
struct PublicHttpSamplingTool;
impl ToolHandler for PublicHttpSamplingTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_SAMPLING_TOOL_NAME.to_owned(),
description: Some("Proves live facade HTTP final sampling input_required".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, _ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
Ok(vec![Content::text("exact legacy result")])
}
fn declares_final_mrtr(&self) -> bool {
true
}
fn call_final_outcome(
&self,
ctx: &McpContext,
_arguments: serde_json::Value,
) -> McpResult<FinalToolOutcome> {
let sampling = ctx.final_sampling(
"sample",
serde_json::from_value(json!({
"messages": [{
"role": "assistant",
"content": {
"type": "tool_use",
"id": "weather-request",
"name": "weather",
"input": {"city": "Boston"},
},
}],
"maxTokens": 16,
"tools": [{
"name": "weather",
"inputSchema": {"type": "object"},
}],
"toolChoice": {"mode": "required"},
}))
.map_err(|error| McpError::internal_error(error.to_string()))?,
)?;
Ok(FinalToolOutcome::InputRequired(
sampling.into_input_required()?,
))
}
}
/// Live modern HTTP tool that returns framework-issued MRTR roots input.
struct PublicHttpRootsTool;
impl ToolHandler for PublicHttpRootsTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_ROOTS_TOOL_NAME.to_owned(),
description: Some("Proves live facade HTTP final roots input_required".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, _ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
Ok(vec![Content::text("exact legacy result")])
}
fn declares_final_mrtr(&self) -> bool {
true
}
fn call_final_outcome(
&self,
ctx: &McpContext,
_arguments: serde_json::Value,
) -> McpResult<FinalToolOutcome> {
let roots = ctx.final_roots("roots", FinalEmbeddedRootsListParams::default())?;
Ok(FinalToolOutcome::InputRequired(
roots.into_input_required()?,
))
}
}
/// Live modern HTTP tool that returns framework-issued MRTR URL elicitation.
struct PublicHttpUrlElicitationTool;
impl ToolHandler for PublicHttpUrlElicitationTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_URL_ELICITATION_TOOL_NAME.to_owned(),
description: Some(
"Proves live facade HTTP final URL elicitation input_required".to_owned(),
),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, _ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
Ok(vec![Content::text("exact legacy result")])
}
fn declares_final_mrtr(&self) -> bool {
true
}
fn call_final_outcome(
&self,
ctx: &McpContext,
_arguments: serde_json::Value,
) -> McpResult<FinalToolOutcome> {
let elicitation = ctx.final_elicitation_url(
"approval",
"Approve this operation",
"https://example.com/approve",
)?;
Ok(FinalToolOutcome::InputRequired(
elicitation.into_input_required()?,
))
}
fn call_final_outcome_async_resuming_in_request<'a>(
&'a self,
ctx: &'a McpContext,
_request_cx: &'a Cx,
arguments: serde_json::Value,
resume_inputs: Option<&'a fastmcp_rust::MrtrCompletedInputs>,
) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
Box::pin(async move {
match resume_inputs {
Some(inputs) => match public_http_url_elicitation_complete(inputs) {
Ok(result) => Outcome::Ok(result),
Err(error) => Outcome::Err(error),
},
None => match self.call_final_outcome(ctx, arguments) {
Ok(result) => Outcome::Ok(result),
Err(error) => Outcome::Err(error),
},
}
})
}
}
fn public_http_form_elicitation(ctx: &McpContext) -> McpResult<fastmcp_rust::InputRequiredResult> {
ctx.final_elicitation_form(
"approval",
"Approve this operation",
json!({
"type": "object",
"properties": {"approved": {"type": "boolean"}},
"required": ["approved"],
}),
)?
.into_input_required()
}
fn public_http_url_elicitation_complete(
completed_inputs: &fastmcp_rust::MrtrCompletedInputs,
) -> McpResult<FinalToolOutcome> {
let elicitation = completed_inputs
.elicitation("approval")?
.ok_or_else(|| McpError::internal_error("URL elicitation input was not preserved"))?;
let action = if elicitation.is_accepted() {
"accept"
} else if elicitation.is_declined() {
"decline"
} else {
"cancel"
};
Ok(FinalToolOutcome::Complete(CompleteResult::new(
FinalCallToolResult {
content: vec![ContentBlock::text(format!("url-elicit:{action}"))],
is_error: false,
structured_content: None,
},
ResultMeta::empty(),
)))
}
fn public_http_form_elicitation_action(
completed_inputs: &fastmcp_rust::MrtrCompletedInputs,
) -> McpResult<String> {
let elicitation = completed_inputs
.elicitation("approval")?
.ok_or_else(|| McpError::internal_error("form elicitation input was not preserved"))?;
if elicitation.is_accepted() {
Ok(format!(
"form-elicit:{}",
elicitation.get_bool("approved").unwrap_or(false)
))
} else if elicitation.is_declined() {
Ok("form-elicit:decline".to_owned())
} else {
Ok("form-elicit:cancel".to_owned())
}
}
fn public_http_form_elicitation_resource_complete(
completed_inputs: &fastmcp_rust::MrtrCompletedInputs,
) -> McpResult<FinalMethodOutcome<FinalReadResourceResult>> {
let text = public_http_form_elicitation_action(completed_inputs)?;
Ok(FinalMethodOutcome::Complete(CompleteResult::new(
FinalReadResourceResult {
contents: vec![EmbeddedResourceContents::Text {
uri: fastmcp_rust::FinalAbsoluteUri::parse(PUBLIC_HTTP_ELICITATION_RESOURCE_URI)
.expect("the public HTTP elicitation resource URI is absolute"),
text,
mime_type: Some("text/plain".to_owned()),
meta: None,
additional: BTreeMap::new(),
}],
ttl_ms: CacheTtl::milliseconds(7),
cache_scope: CacheScope::Private,
},
ResultMeta::empty(),
)))
}
fn public_http_form_elicitation_prompt_complete(
completed_inputs: &fastmcp_rust::MrtrCompletedInputs,
) -> McpResult<FinalMethodOutcome<FinalGetPromptResult>> {
let text = public_http_form_elicitation_action(completed_inputs)?;
Ok(FinalMethodOutcome::Complete(CompleteResult::new(
FinalGetPromptResult {
description: Some("typed form elicitation prompt result".to_owned()),
messages: vec![FinalPromptMessage {
role: Role::Assistant,
content: ContentBlock::text(text),
}],
},
ResultMeta::empty(),
)))
}
fn public_modern_elicitation_follow_handlers() -> modern::ReverseRequestHandlers {
modern::ReverseRequestHandlers::new().with_modern_elicitation_create(
|_cx, _cancellation, params| {
Box::pin(async move {
Ok(match params {
fastmcp_protocol::ElicitRequestParams::Url(_) => {
fastmcp_protocol::ElicitResult::accept_url()
}
fastmcp_protocol::ElicitRequestParams::Form(_) => {
fastmcp_protocol::ElicitResult::accept(HashMap::from([(
"approved".to_owned(),
fastmcp_protocol::ElicitContentValue::Bool(true),
)]))
}
})
})
},
)
}
/// Live modern HTTP resource that returns framework-issued MRTR elicitation.
struct PublicHttpElicitationResource;
impl ResourceHandler for PublicHttpElicitationResource {
fn definition(&self) -> Resource {
Resource {
uri: PUBLIC_HTTP_ELICITATION_RESOURCE_URI.to_owned(),
name: "public-http-e2e-elicitation".to_owned(),
description: Some("Proves live facade HTTP resource input_required".to_owned()),
mime_type: None,
icon: None,
version: None,
tags: Vec::new(),
}
}
fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
Ok(vec![ResourceContent {
uri: PUBLIC_HTTP_ELICITATION_RESOURCE_URI.to_owned(),
mime_type: None,
text: Some("exact legacy resource".to_owned()),
blob: None,
}])
}
fn declares_final_mrtr(&self) -> bool {
true
}
fn read_final_outcome(
&self,
ctx: &McpContext,
) -> McpResult<FinalMethodOutcome<fastmcp_rust::FinalReadResourceResult>> {
public_http_form_elicitation(ctx).map(FinalMethodOutcome::InputRequired)
}
fn read_final_outcome_async_with_uri_resuming_in_request<'a>(
&'a self,
ctx: &'a McpContext,
_request_cx: &'a Cx,
_uri: &'a str,
_params: &'a HashMap<String, String>,
resume_inputs: Option<&'a fastmcp_rust::MrtrCompletedInputs>,
) -> BoxFuture<'a, McpOutcome<FinalMethodOutcome<FinalReadResourceResult>>> {
Box::pin(async move {
match resume_inputs {
Some(inputs) => match public_http_form_elicitation_resource_complete(inputs) {
Ok(result) => Outcome::Ok(result),
Err(error) => Outcome::Err(error),
},
None => match self.read_final_outcome(ctx) {
Ok(result) => Outcome::Ok(result),
Err(error) => Outcome::Err(error),
},
}
})
}
}
/// Live modern HTTP prompt that returns framework-issued MRTR elicitation.
struct PublicHttpElicitationPrompt;
impl PromptHandler for PublicHttpElicitationPrompt {
fn definition(&self) -> Prompt {
Prompt {
name: PUBLIC_HTTP_ELICITATION_PROMPT_NAME.to_owned(),
description: Some("Proves live facade HTTP prompt input_required".to_owned()),
arguments: Vec::new(),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn get(
&self,
_ctx: &McpContext,
_arguments: HashMap<String, String>,
) -> McpResult<Vec<PromptMessage>> {
Ok(vec![PromptMessage {
role: Role::User,
content: Content::text("exact legacy prompt"),
}])
}
fn declares_final_mrtr(&self) -> bool {
true
}
fn get_final_outcome(
&self,
ctx: &McpContext,
_arguments: HashMap<String, String>,
) -> McpResult<FinalMethodOutcome<fastmcp_rust::FinalGetPromptResult>> {
public_http_form_elicitation(ctx).map(FinalMethodOutcome::InputRequired)
}
fn get_final_outcome_async_resuming_in_request<'a>(
&'a self,
ctx: &'a McpContext,
_request_cx: &'a Cx,
arguments: HashMap<String, String>,
resume_inputs: Option<&'a fastmcp_rust::MrtrCompletedInputs>,
) -> BoxFuture<'a, McpOutcome<FinalMethodOutcome<FinalGetPromptResult>>> {
Box::pin(async move {
match resume_inputs {
Some(inputs) => match public_http_form_elicitation_prompt_complete(inputs) {
Ok(result) => Outcome::Ok(result),
Err(error) => Outcome::Err(error),
},
None => match self.get_final_outcome(ctx, arguments) {
Ok(result) => Outcome::Ok(result),
Err(error) => Outcome::Err(error),
},
}
})
}
}
/// Starts one ModernOnly facade whose only tool is live final sampling.
fn spawn_modern_sampling_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("sampling HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-sampling", "1.0.0")
.tool(PublicHttpSamplingTool)
.tool(PublicHttpRootsTool)
.tool(PublicHttpUrlElicitationTool)
.resource(PublicHttpElicitationResource)
.prompt(PublicHttpElicitationPrompt)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("sampling facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("sampling facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("sampling HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("sampling facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("sampling facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("sampling facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("sampling facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
fn assert_live_input_required(result: &fastmcp_rust::InputRequiredResult, key: &str, kind: &str) {
assert!(
result.request_state().is_some(),
"{kind} must return framework-issued requestState"
);
assert!(
result
.input_requests()
.is_some_and(|requests| requests.get(key).is_some()),
"{kind} must retain its {key} input request"
);
}
#[test]
fn e2e_public_http_result_verbs_return_live_input_required() {
let cx = Cx::for_request();
let server = spawn_modern_sampling_http_server();
let mut capabilities = ClientCapabilities::default();
capabilities.sampling = Some(Default::default());
capabilities.roots = serde_json::from_value(json!({})).expect("roots capability is valid");
capabilities.elicitation = serde_json::from_value(json!({"form": {}, "url": {}}))
.expect("form and url elicitation capabilities are valid");
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-mrtr-client", "1.0.0")
.capabilities(capabilities)
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the live MRTR route");
let tool = runtime_block_on_bounded(
&cx,
client.call_tool_result(&cx, PUBLIC_HTTP_SAMPLING_TOOL_NAME, json!({})),
)
.expect("live bind_http final sampling must return a typed tools/call result");
let FinalCoreResult::ToolsCallInputRequired { result, .. } = tool else {
panic!("live bind_http final sampling must keep input_required: {tool:?}");
};
assert_live_input_required(&result, "sample", "tools/call");
let roots = runtime_block_on_bounded(
&cx,
client.call_tool_result(&cx, PUBLIC_HTTP_ROOTS_TOOL_NAME, json!({})),
)
.expect("live bind_http final roots must return a typed tools/call result");
let FinalCoreResult::ToolsCallInputRequired { result, .. } = roots else {
panic!("live bind_http final roots must keep input_required: {roots:?}");
};
assert_live_input_required(&result, "roots", "tools/call");
let mut no_roots_capabilities = ClientCapabilities::default();
no_roots_capabilities.sampling = Some(Default::default());
no_roots_capabilities.elicitation =
serde_json::from_value(json!({"form": {}})).expect("form elicitation capability is valid");
let mut no_roots_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-mrtr-no-roots", "1.0.0")
.capabilities(no_roots_capabilities)
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects without advertising roots");
let missing_roots = runtime_block_on_bounded(
&cx,
no_roots_client.call_tool_result(&cx, PUBLIC_HTTP_ROOTS_TOOL_NAME, json!({})),
)
.expect("live bind_http ctx.final_roots must return a typed tools/call result");
let FinalCoreResult::ToolsCall { result, .. } = missing_roots else {
panic!("missing roots capability must fail closed as a tool error: {missing_roots:?}");
};
assert!(
result.payload.is_error,
"missing roots capability must fail closed: {result:?}"
);
assert!(
result.payload.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text.contains("not advertised by the client"),
_ => false,
}),
"missing roots capability must name the capability gate: {result:?}"
);
let url_elicitation = runtime_block_on_bounded(
&cx,
client.call_tool_result(&cx, PUBLIC_HTTP_URL_ELICITATION_TOOL_NAME, json!({})),
)
.expect("live bind_http final URL elicitation must return a typed tools/call result");
let FinalCoreResult::ToolsCallInputRequired { result, .. } = url_elicitation else {
panic!(
"live bind_http final URL elicitation must keep input_required: {url_elicitation:?}"
);
};
assert_live_input_required(&result, "approval", "tools/call");
let mut no_url_capabilities = ClientCapabilities::default();
no_url_capabilities.sampling = Some(Default::default());
no_url_capabilities.roots =
serde_json::from_value(json!({})).expect("roots capability is valid");
no_url_capabilities.elicitation =
serde_json::from_value(json!({"form": {}})).expect("form elicitation capability is valid");
let mut no_url_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-mrtr-no-url", "1.0.0")
.capabilities(no_url_capabilities)
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects without advertising URL elicitation");
let missing_url = runtime_block_on_bounded(
&cx,
no_url_client.call_tool_result(&cx, PUBLIC_HTTP_URL_ELICITATION_TOOL_NAME, json!({})),
)
.expect("live bind_http ctx.final_elicitation_url must return a typed tools/call result");
let FinalCoreResult::ToolsCall { result, .. } = missing_url else {
panic!(
"missing URL elicitation capability must fail closed as a tool error: {missing_url:?}"
);
};
assert!(
result.payload.is_error,
"missing URL elicitation capability must fail closed: {result:?}"
);
assert!(
result.payload.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text.contains("not advertised by the client"),
_ => false,
}),
"missing URL elicitation capability must name the capability gate: {result:?}"
);
let resource = runtime_block_on_bounded(
&cx,
client.read_resource_result(&cx, PUBLIC_HTTP_ELICITATION_RESOURCE_URI),
)
.expect("live bind_http final resource elicitation must return a typed resources/read result");
let FinalCoreResult::ResourcesReadInputRequired { result, .. } = resource else {
panic!("live bind_http resource elicitation must keep input_required: {resource:?}");
};
assert_live_input_required(&result, "approval", "resources/read");
let prompt = runtime_block_on_bounded(
&cx,
client.get_prompt_result(&cx, PUBLIC_HTTP_ELICITATION_PROMPT_NAME, HashMap::new()),
)
.expect("live bind_http final prompt elicitation must return a typed prompts/get result");
let FinalCoreResult::PromptsGetInputRequired { result, .. } = prompt else {
panic!("live bind_http prompt elicitation must keep input_required: {prompt:?}");
};
assert_live_input_required(&result, "approval", "prompts/get");
server.shutdown();
}
#[test]
fn e2e_public_http_typed_verbs_cannot_resume_stateless_elicitation_request_state() {
let cx = Cx::for_request();
let server = spawn_modern_sampling_http_server();
let mut capabilities = ClientCapabilities::default();
capabilities.elicitation = serde_json::from_value(json!({"form": {}, "url": {}}))
.expect("form and url elicitation capabilities are valid");
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-elicit-stateless", "1.0.0")
.capabilities(capabilities)
.modern_reverse_request_handlers(public_modern_elicitation_follow_handlers())
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade installs an elicitation handler before discovery");
let retry = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_URL_ELICITATION_TOOL_NAME, json!({})),
);
let retry = match retry {
Ok(result) => {
panic!("stateless HTTP must not complete elicitation across a second POST: {result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
retry.contains("RequestCancelled")
|| retry.contains("requestState")
|| retry.contains("InvalidRequest")
|| retry.contains("session"),
"a second HTTP POST must not resume the first POST's elicitation requestState: {retry}"
);
server.shutdown();
}
#[test]
fn e2e_public_http_typed_verbs_honor_pre_send_cancellation() {
let cx = Cx::for_request();
let server = spawn_modern_facade_http_server(false, None, false);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-pre-send-cancel", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects before pre-send cancellation");
runtime_block_on_bounded(&cx, client.ping(&cx))
.expect("live bind_http modern ping completes before local cancellation");
let cancellation = McpRequestCancellation::new();
cancellation.cancel();
let ping = runtime_block_on_bounded(&cx, client.ping_with_cancellation(&cx, &cancellation))
.expect_err("pre-send HTTP ping cancellation must reject locally");
assert!(matches!(
ping,
modern::HttpClientError::CoreResult(error)
if error.code == McpErrorCode::RequestCancelled
));
let list = runtime_block_on_bounded(
&cx,
client.list_tools_with_cancellation(&cx, &cancellation, None),
)
.expect_err("pre-send HTTP list_tools cancellation must reject locally");
assert!(matches!(
list,
modern::HttpClientError::CoreResult(error)
if error.code == McpErrorCode::RequestCancelled
));
let resources = runtime_block_on_bounded(
&cx,
client.list_resources_with_cancellation(&cx, &cancellation, None),
)
.expect_err("pre-send HTTP list_resources cancellation must reject locally");
assert!(matches!(
resources,
modern::HttpClientError::CoreResult(error)
if error.code == McpErrorCode::RequestCancelled
));
let templates = runtime_block_on_bounded(
&cx,
client.list_resource_templates_with_cancellation(&cx, &cancellation, None),
)
.expect_err("pre-send HTTP list_resource_templates cancellation must reject locally");
assert!(matches!(
templates,
modern::HttpClientError::CoreResult(error)
if error.code == McpErrorCode::RequestCancelled
));
let prompts = runtime_block_on_bounded(
&cx,
client.list_prompts_with_cancellation(&cx, &cancellation, None),
)
.expect_err("pre-send HTTP list_prompts cancellation must reject locally");
assert!(matches!(
prompts,
modern::HttpClientError::CoreResult(error)
if error.code == McpErrorCode::RequestCancelled
));
let call = runtime_block_on_bounded(
&cx,
client.call_tool_with_cancellation(
&cx,
&cancellation,
PUBLIC_HTTP_TOOL_NAME,
json!({ "value": PUBLIC_HTTP_TOOL_ARGUMENT }),
),
)
.expect_err("pre-send HTTP call_tool cancellation must reject locally");
assert!(matches!(
call,
modern::HttpClientError::CoreResult(error)
if error.code == McpErrorCode::RequestCancelled
));
let resource = runtime_block_on_bounded(
&cx,
client.read_resource_with_cancellation(&cx, &cancellation, PUBLIC_HTTP_RESOURCE_URI),
)
.expect_err("pre-send HTTP read_resource cancellation must reject locally");
assert!(matches!(
resource,
modern::HttpClientError::CoreResult(error)
if error.code == McpErrorCode::RequestCancelled
));
let prompt = runtime_block_on_bounded(
&cx,
client.get_prompt_with_cancellation(
&cx,
&cancellation,
PUBLIC_HTTP_PROMPT_NAME,
HashMap::from([("subject".to_owned(), PUBLIC_HTTP_TOOL_ARGUMENT.to_owned())]),
),
)
.expect_err("pre-send HTTP get_prompt cancellation must reject locally");
assert!(matches!(
prompt,
modern::HttpClientError::CoreResult(error)
if error.code == McpErrorCode::RequestCancelled
));
let completion = runtime_block_on_bounded(
&cx,
client.complete_with_cancellation(
&cx,
&cancellation,
modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: PUBLIC_HTTP_PROMPT_NAME.to_owned(),
title: "Public HTTP E2E Prompt".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
context: None,
},
),
)
.expect_err("pre-send HTTP complete cancellation must reject locally");
assert!(matches!(
completion,
modern::HttpClientError::CoreResult(error)
if error.code == McpErrorCode::RequestCancelled
));
runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("the same HTTP session remains usable after local cancellation");
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_typed_verbs_honor_pre_send_cancellation() {
let cx = Cx::for_request();
let server = spawn_legacy_instructions_http_server(false);
let mut client = connect_legacy_http_client(
&cx,
server.address(),
"e2e-public-http-legacy-pre-send-cancel",
);
runtime_block_on_bounded_named(&cx, "exact-2024 HTTP ping before cancel", client.ping(&cx))
.expect("live exact-2024 HTTP SSE ping completes before local cancellation");
let cancellation = McpRequestCancellation::new();
cancellation.cancel();
let ping = runtime_block_on_bounded(&cx, client.ping_with_cancellation(&cx, &cancellation))
.expect_err("pre-send exact-2024 HTTP ping cancellation must reject locally");
assert!(matches!(
ping,
legacy_2024::HttpClientError::CoreResult(ref error)
if error.code == McpErrorCode::RequestCancelled
));
let list = runtime_block_on_bounded(
&cx,
client.list_tools_with_cancellation(&cx, &cancellation, ListToolsParams::default()),
)
.expect_err("pre-send exact-2024 HTTP list_tools cancellation must reject locally");
assert!(matches!(
list,
legacy_2024::HttpClientError::CoreResult(ref error)
if error.code == McpErrorCode::RequestCancelled
));
let resources = runtime_block_on_bounded(
&cx,
client.list_resources_with_cancellation(&cx, &cancellation, ListResourcesParams::default()),
)
.expect_err("pre-send exact-2024 HTTP list_resources cancellation must reject locally");
assert!(matches!(
resources,
legacy_2024::HttpClientError::CoreResult(ref error)
if error.code == McpErrorCode::RequestCancelled
));
let templates = runtime_block_on_bounded(
&cx,
client.list_resource_templates_with_cancellation(
&cx,
&cancellation,
ListResourceTemplatesParams::default(),
),
)
.expect_err(
"pre-send exact-2024 HTTP list_resource_templates cancellation must reject locally",
);
assert!(matches!(
templates,
legacy_2024::HttpClientError::CoreResult(ref error)
if error.code == McpErrorCode::RequestCancelled
));
let prompts = runtime_block_on_bounded(
&cx,
client.list_prompts_with_cancellation(&cx, &cancellation, ListPromptsParams::default()),
)
.expect_err("pre-send exact-2024 HTTP list_prompts cancellation must reject locally");
assert!(matches!(
prompts,
legacy_2024::HttpClientError::CoreResult(ref error)
if error.code == McpErrorCode::RequestCancelled
));
let call = runtime_block_on_bounded(
&cx,
client.call_tool_with_cancellation(
&cx,
&cancellation,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_TOOL_NAME.to_owned(),
arguments: Some(json!({ "value": PUBLIC_HTTP_TOOL_ARGUMENT })),
meta: None,
},
),
)
.expect_err("pre-send exact-2024 HTTP call_tool cancellation must reject locally");
assert!(matches!(
call,
legacy_2024::HttpClientError::CoreResult(ref error)
if error.code == McpErrorCode::RequestCancelled
));
let resource = runtime_block_on_bounded(
&cx,
client.read_resource_with_cancellation(
&cx,
&cancellation,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_RESOURCE_URI.to_owned(),
meta: None,
},
),
)
.expect_err("pre-send exact-2024 HTTP read_resource cancellation must reject locally");
assert!(matches!(
resource,
legacy_2024::HttpClientError::CoreResult(ref error)
if error.code == McpErrorCode::RequestCancelled
));
let prompt = runtime_block_on_bounded(
&cx,
client.get_prompt_with_cancellation(
&cx,
&cancellation,
legacy_2024::GetPromptParams {
name: PUBLIC_HTTP_PROMPT_NAME.to_owned(),
arguments: Some(HashMap::from([(
"subject".to_owned(),
PUBLIC_HTTP_TOOL_ARGUMENT.to_owned(),
)])),
meta: None,
},
),
)
.expect_err("pre-send exact-2024 HTTP get_prompt cancellation must reject locally");
assert!(matches!(
prompt,
legacy_2024::HttpClientError::CoreResult(ref error)
if error.code == McpErrorCode::RequestCancelled
));
let completion = runtime_block_on_bounded(
&cx,
client.complete_with_cancellation(
&cx,
&cancellation,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt {
name: PUBLIC_HTTP_PROMPT_NAME.to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
meta: None,
},
),
)
.expect_err("pre-send exact-2024 HTTP complete cancellation must reject locally");
assert!(matches!(
completion,
legacy_2024::HttpClientError::CoreResult(ref error)
if error.code == McpErrorCode::RequestCancelled
));
let admitted = McpRequestCancellation::new();
let echoed = runtime_block_on_bounded(
&cx,
client.call_tool_with_cancellation(
&cx,
&admitted,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_TOOL_NAME.to_owned(),
arguments: Some(json!({ "value": PUBLIC_HTTP_TOOL_ARGUMENT })),
meta: None,
},
),
)
.expect("an uncancelled exact-2024 HTTP tools/call still reaches the handler");
assert_eq!(
legacy_http_tool_text(&echoed).as_deref(),
Some(PUBLIC_HTTP_TOOL_TEXT),
"uncancelled exact-2024 HTTP tools/call must retain the handler text: {echoed:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP ping after local cancel",
client.ping(&cx),
)
.expect("the same exact-2024 HTTP session remains usable after local cancellation");
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_set_log_level_stamps_request_metadata() {
let cx = Cx::for_request();
let server = spawn_modern_facade_http_server(false, None, false);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-log-level", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects before logLevel configuration");
runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("JSON tools/list succeeds before request logLevel is configured");
assert_eq!(client.log_level(), None);
client
.set_log_level(modern::LoggingLevel::Info)
.expect("modern HTTP set_log_level stores request metadata locally");
assert_eq!(client.log_level(), Some(modern::LoggingLevel::Info));
runtime_block_on_bounded(&cx, client.list_tools(&cx, None)).expect(
"info logLevel is request metadata; the public JSON+SSE Accept still completes tools/list",
);
client
.set_log_level(modern::LoggingLevel::Emergency)
.expect("emergency logLevel still stores request metadata locally");
runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("changing only the logLevel rank cannot break the same public tools/list verb");
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_set_log_level_retains_request_scoped_message_notifications() {
let cx = Cx::for_request();
let server = spawn_modern_facade_http_server(false, None, false);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-log-notify", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects before log notification retention");
assert!(
client.take_server_notifications().is_empty(),
"no request has produced a request-scoped log yet"
);
client
.set_log_level(modern::LoggingLevel::Info)
.expect("info logLevel is stored as request metadata");
runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("info logLevel forces the request-owned SSE body that can carry logs");
let info_notifications = client.take_server_notifications();
assert!(
info_notifications.iter().any(|notification| matches!(
notification,
modern::ServerNotification::Message(message)
if message.level == modern::LoggingLevel::Info
)),
"live bind_http must retain notifications/message after set_log_level(Info): {info_notifications:?}"
);
assert!(
client.take_server_notifications().is_empty(),
"take_server_notifications must drain the retained queue"
);
client
.set_log_level(modern::LoggingLevel::Emergency)
.expect("emergency logLevel still stores request metadata locally");
runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("raising the floor cannot break the same public tools/list verb");
let emergency_notifications = client.take_server_notifications();
assert!(
!emergency_notifications.iter().any(|notification| matches!(
notification,
modern::ServerNotification::Message(message)
if message.level == modern::LoggingLevel::Info
)),
"raising only the logLevel floor must suppress the info notification: {emergency_notifications:?}"
);
drop(client);
server.shutdown();
}
/// Live modern HTTP tool that emits `ctx.info` so request-scoped logs are observable.
struct PublicHttpLogTool;
impl ToolHandler for PublicHttpLogTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_LOG_TOOL_NAME.to_owned(),
description: Some("Proves live facade HTTP handler log notifications".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
ctx.info(PUBLIC_HTTP_HANDLER_LOG_TEXT);
Ok(vec![Content::text("logged")])
}
}
const PUBLIC_HTTP_LOG_RESOURCE_URI: &str = "test://public-http-e2e/log";
const PUBLIC_HTTP_RESOURCE_LOG_TEXT: &str = "public-http-resource-info";
/// Live modern HTTP resource that emits `ctx.info` on read.
struct PublicHttpLogResource;
impl ResourceHandler for PublicHttpLogResource {
fn definition(&self) -> Resource {
Resource {
uri: PUBLIC_HTTP_LOG_RESOURCE_URI.to_owned(),
name: "public-http-e2e-log".to_owned(),
description: Some("Proves live facade HTTP resource log notifications".to_owned()),
mime_type: Some("text/plain".to_owned()),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn read(&self, ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
ctx.info(PUBLIC_HTTP_RESOURCE_LOG_TEXT);
Ok(vec![ResourceContent {
uri: PUBLIC_HTTP_LOG_RESOURCE_URI.to_owned(),
mime_type: None,
text: Some("logged".to_owned()),
blob: None,
}])
}
}
const PUBLIC_HTTP_LOG_PROMPT_NAME: &str = "public-http-e2e-log-prompt";
const PUBLIC_HTTP_PROMPT_LOG_TEXT: &str = "public-http-prompt-info";
/// Live modern HTTP prompt that emits `ctx.info` on get.
struct PublicHttpLogPrompt;
impl PromptHandler for PublicHttpLogPrompt {
fn definition(&self) -> Prompt {
Prompt {
name: PUBLIC_HTTP_LOG_PROMPT_NAME.to_owned(),
description: Some("Proves live facade HTTP prompt log notifications".to_owned()),
arguments: Vec::new(),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn get(
&self,
ctx: &McpContext,
_arguments: HashMap<String, String>,
) -> McpResult<Vec<PromptMessage>> {
ctx.info(PUBLIC_HTTP_PROMPT_LOG_TEXT);
Ok(vec![PromptMessage {
role: Role::User,
content: Content::text("logged"),
}])
}
}
fn spawn_modern_log_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("log HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-log", "1.0.0")
.tool(PublicHttpLogTool)
.resource(PublicHttpLogResource)
.prompt(PublicHttpLogPrompt)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("log facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("log facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("log HTTP server startup receiver went away".to_owned());
}
bound
.serve(&cx)
.await
.map_err(|error| format!("log facade HTTP server stopped unexpectedly: {error}"))
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("log facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("log facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("log facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_ctx_info_is_retained_after_set_log_level() {
let cx = Cx::for_request();
let server = spawn_modern_log_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-ctx-info", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects before handler log emission");
client
.set_log_level(modern::LoggingLevel::Info)
.expect("info logLevel is stored as request metadata");
runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_LOG_TOOL_NAME, json!({})),
)
.expect("ctx.info must not prevent the same tools/call from completing");
let info_notifications = client.take_server_notifications();
assert!(
info_notifications.iter().any(|notification| matches!(
notification,
modern::ServerNotification::Message(message)
if message.level == modern::LoggingLevel::Info
&& message.data == json!(PUBLIC_HTTP_HANDLER_LOG_TEXT)
)),
"live bind_http must retain ctx.info after set_log_level(Info): {info_notifications:?}"
);
runtime_block_on_bounded(&cx, client.read_resource(&cx, PUBLIC_HTTP_LOG_RESOURCE_URI))
.expect("ctx.info must not prevent the same resources/read from completing");
let resource_notifications = client.take_server_notifications();
assert!(
resource_notifications.iter().any(|notification| matches!(
notification,
modern::ServerNotification::Message(message)
if message.level == modern::LoggingLevel::Info
&& message.data == json!(PUBLIC_HTTP_RESOURCE_LOG_TEXT)
)),
"live bind_http must retain resource ctx.info after set_log_level(Info): {resource_notifications:?}"
);
runtime_block_on_bounded(
&cx,
client.get_prompt(&cx, PUBLIC_HTTP_LOG_PROMPT_NAME, HashMap::new()),
)
.expect("ctx.info must not prevent the same prompts/get from completing");
let prompt_notifications = client.take_server_notifications();
assert!(
prompt_notifications.iter().any(|notification| matches!(
notification,
modern::ServerNotification::Message(message)
if message.level == modern::LoggingLevel::Info
&& message.data == json!(PUBLIC_HTTP_PROMPT_LOG_TEXT)
)),
"live bind_http must retain prompt ctx.info after set_log_level(Info): {prompt_notifications:?}"
);
client
.set_log_level(modern::LoggingLevel::Emergency)
.expect("emergency logLevel still stores request metadata locally");
runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_LOG_TOOL_NAME, json!({})),
)
.expect("raising only the logLevel floor cannot break the same public tools/call");
let emergency_notifications = client.take_server_notifications();
assert!(
!emergency_notifications.iter().any(|notification| matches!(
notification,
modern::ServerNotification::Message(message)
if message.data == json!(PUBLIC_HTTP_HANDLER_LOG_TEXT)
)),
"raising only the logLevel floor must suppress ctx.info: {emergency_notifications:?}"
);
runtime_block_on_bounded(&cx, client.read_resource(&cx, PUBLIC_HTTP_LOG_RESOURCE_URI))
.expect("raising only the logLevel floor cannot break resources/read");
runtime_block_on_bounded(
&cx,
client.get_prompt(&cx, PUBLIC_HTTP_LOG_PROMPT_NAME, HashMap::new()),
)
.expect("raising only the logLevel floor cannot break prompts/get");
let emergency_follow = client.take_server_notifications();
assert!(
!emergency_follow.iter().any(|notification| matches!(
notification,
modern::ServerNotification::Message(message)
if message.data == json!(PUBLIC_HTTP_RESOURCE_LOG_TEXT)
|| message.data == json!(PUBLIC_HTTP_PROMPT_LOG_TEXT)
)),
"raising only the logLevel floor must suppress resource and prompt ctx.info: {emergency_follow:?}"
);
drop(client);
server.shutdown();
}
const PUBLIC_HTTP_PROGRESS_TOOL_NAME: &str = "public-http-e2e-progress";
/// Live modern HTTP tool that reports progress when the request carries a token.
struct PublicHttpProgressTool;
impl ToolHandler for PublicHttpProgressTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_PROGRESS_TOOL_NAME.to_owned(),
description: Some("Proves live facade HTTP progress notifications".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
ctx.report_progress(0.5, Some("halfway"));
Ok(vec![Content::text("progressed")])
}
}
const PUBLIC_HTTP_PROGRESS_RESOURCE_URI: &str = "test://public-http-e2e/progress";
const PUBLIC_HTTP_PROGRESS_PROMPT_NAME: &str = "public-http-e2e-progress-prompt";
/// Live modern HTTP resource that reports progress when the request carries a token.
struct PublicHttpProgressResource;
impl ResourceHandler for PublicHttpProgressResource {
fn definition(&self) -> Resource {
Resource {
uri: PUBLIC_HTTP_PROGRESS_RESOURCE_URI.to_owned(),
name: "public-http-e2e-progress".to_owned(),
description: Some("Proves live facade HTTP resource progress notifications".to_owned()),
mime_type: Some("text/plain".to_owned()),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn read(&self, ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
ctx.report_progress(0.5, Some("resource-halfway"));
Ok(vec![ResourceContent {
uri: PUBLIC_HTTP_PROGRESS_RESOURCE_URI.to_owned(),
mime_type: None,
text: Some("progressed".to_owned()),
blob: None,
}])
}
}
/// Live modern HTTP prompt that reports progress when the request carries a token.
struct PublicHttpProgressPrompt;
impl PromptHandler for PublicHttpProgressPrompt {
fn definition(&self) -> Prompt {
Prompt {
name: PUBLIC_HTTP_PROGRESS_PROMPT_NAME.to_owned(),
description: Some("Proves live facade HTTP prompt progress notifications".to_owned()),
arguments: Vec::new(),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn get(
&self,
ctx: &McpContext,
_arguments: HashMap<String, String>,
) -> McpResult<Vec<PromptMessage>> {
ctx.report_progress(0.5, Some("prompt-halfway"));
Ok(vec![PromptMessage {
role: Role::User,
content: Content::text("progressed"),
}])
}
}
fn spawn_modern_progress_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("progress HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-progress", "1.0.0")
.tool(PublicHttpProgressTool)
.resource(PublicHttpProgressResource)
.prompt(PublicHttpProgressPrompt)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("progress facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("progress facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("progress HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("progress facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("progress facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("progress facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("progress facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_progress_marker_is_retained_from_request_sse() {
let cx = Cx::for_request();
let server = spawn_modern_progress_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-progress", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects before progress emission");
runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_PROGRESS_TOOL_NAME, json!({})),
)
.expect("a tools/call without a progress token still completes");
assert!(
client.take_progress_notifications().is_empty(),
"without a progressToken the handler must not emit request-scoped progress"
);
let marker = modern::ProgressMarker::from("http-progress");
runtime_block_on_bounded(
&cx,
client.call_tool_with_progress_marker(
&cx,
PUBLIC_HTTP_PROGRESS_TOOL_NAME,
json!({}),
marker.clone(),
),
)
.expect("a progressToken must not prevent the same tools/call from completing");
let progress = client.take_progress_notifications();
assert!(
progress.iter().any(|notification| {
notification.progress_token == marker
&& notification.message.as_deref() == Some("halfway")
}),
"live bind_http must retain notifications/progress after a progressToken: {progress:?}"
);
assert!(
client.take_progress_notifications().is_empty(),
"take_progress_notifications must drain the retained queue"
);
runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_PROGRESS_RESOURCE_URI),
)
.expect("a resources/read without a progress token still completes");
assert!(
client.take_progress_notifications().is_empty(),
"without a progressToken the resource handler must not emit request-scoped progress"
);
let resource_marker = modern::ProgressMarker::from("http-resource-progress");
runtime_block_on_bounded(
&cx,
client.read_resource_with_progress_marker(
&cx,
PUBLIC_HTTP_PROGRESS_RESOURCE_URI,
resource_marker.clone(),
),
)
.expect("a progressToken must not prevent the same resources/read from completing");
let resource_progress = client.take_progress_notifications();
assert!(
resource_progress.iter().any(|notification| {
notification.progress_token == resource_marker
&& notification.message.as_deref() == Some("resource-halfway")
}),
"live bind_http must retain resource notifications/progress after a progressToken: {resource_progress:?}"
);
runtime_block_on_bounded(
&cx,
client.get_prompt(&cx, PUBLIC_HTTP_PROGRESS_PROMPT_NAME, HashMap::new()),
)
.expect("a prompts/get without a progress token still completes");
assert!(
client.take_progress_notifications().is_empty(),
"without a progressToken the prompt handler must not emit request-scoped progress"
);
let prompt_marker = modern::ProgressMarker::from("http-prompt-progress");
runtime_block_on_bounded(
&cx,
client.get_prompt_with_progress_marker(
&cx,
PUBLIC_HTTP_PROGRESS_PROMPT_NAME,
HashMap::new(),
prompt_marker.clone(),
),
)
.expect("a progressToken must not prevent the same prompts/get from completing");
let prompt_progress = client.take_progress_notifications();
assert!(
prompt_progress.iter().any(|notification| {
notification.progress_token == prompt_marker
&& notification.message.as_deref() == Some("prompt-halfway")
}),
"live bind_http must retain prompt notifications/progress after a progressToken: {prompt_progress:?}"
);
assert!(
client.take_progress_notifications().is_empty(),
"take_progress_notifications must drain the retained resource and prompt queues"
);
drop(client);
server.shutdown();
}
const PUBLIC_HTTP_TEMPLATE: &str = "test://public-http-e2e/item/{id}";
const PUBLIC_HTTP_TEMPLATE_NAME: &str = "public-http-e2e-item";
const PUBLIC_HTTP_TEMPLATE_MATCHED_URI: &str = "test://public-http-e2e/item/alpha";
const PUBLIC_HTTP_TEMPLATE_UNMATCHED_URI: &str = "test://public-http-e2e/other/alpha";
/// Live modern HTTP resource whose RFC 6570 template expands on `resources/read`.
struct PublicHttpTemplatedResource {
reads: Arc<AtomicUsize>,
}
impl ResourceHandler for PublicHttpTemplatedResource {
fn definition(&self) -> Resource {
Resource {
uri: PUBLIC_HTTP_TEMPLATE.to_owned(),
name: PUBLIC_HTTP_TEMPLATE_NAME.to_owned(),
description: Some("Proves live facade HTTP RFC 6570 resource templates".to_owned()),
mime_type: Some("text/plain".to_owned()),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn template(&self) -> Option<ResourceTemplate> {
Some(ResourceTemplate {
uri_template: PUBLIC_HTTP_TEMPLATE.to_owned(),
name: PUBLIC_HTTP_TEMPLATE_NAME.to_owned(),
description: Some("Proves live facade HTTP RFC 6570 resource templates".to_owned()),
mime_type: Some("text/plain".to_owned()),
icon: None,
version: None,
tags: Vec::new(),
})
}
fn final_template_definition(&self) -> Option<FinalResourceTemplate> {
Some(FinalResourceTemplate {
uri_template: PUBLIC_HTTP_TEMPLATE.to_owned(),
name: PUBLIC_HTTP_TEMPLATE_NAME.to_owned(),
title: Some("Public HTTP E2E Item".to_owned()),
description: Some("Proves live facade HTTP RFC 6570 resource templates".to_owned()),
icons: None,
mime_type: Some("text/plain".to_owned()),
annotations: None,
meta: None,
})
}
fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
Err(McpError::invalid_request(
"templated resource requires a matched URI",
))
}
fn read_with_uri(
&self,
_ctx: &McpContext,
uri: &str,
params: &HashMap<String, String>,
) -> McpResult<Vec<ResourceContent>> {
self.reads.fetch_add(1, Ordering::SeqCst);
let id = params
.get("id")
.ok_or_else(|| McpError::invalid_params("templated resource is missing id"))?;
Ok(vec![ResourceContent {
uri: uri.to_owned(),
mime_type: Some("text/plain".to_owned()),
text: Some(format!("item:{id}")),
blob: None,
}])
}
}
struct PublicHttpLossyTemplatedResource;
impl ResourceHandler for PublicHttpLossyTemplatedResource {
fn definition(&self) -> Resource {
Resource {
uri: "test://public-http-e2e/item/{id:3}".to_owned(),
name: "lossy-prefix".to_owned(),
description: Some("Must fail closed instead of guessing a reverse match".to_owned()),
mime_type: Some("text/plain".to_owned()),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn template(&self) -> Option<ResourceTemplate> {
Some(ResourceTemplate {
uri_template: "test://public-http-e2e/item/{id:3}".to_owned(),
name: "lossy-prefix".to_owned(),
description: Some("Must fail closed instead of guessing a reverse match".to_owned()),
mime_type: Some("text/plain".to_owned()),
icon: None,
version: None,
tags: Vec::new(),
})
}
fn final_template_definition(&self) -> Option<FinalResourceTemplate> {
Some(FinalResourceTemplate {
uri_template: "test://public-http-e2e/item/{id:3}".to_owned(),
name: "lossy-prefix".to_owned(),
title: None,
description: Some("Must fail closed instead of guessing a reverse match".to_owned()),
icons: None,
mime_type: Some("text/plain".to_owned()),
annotations: None,
meta: None,
})
}
fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
Err(McpError::internal_error(
"a lossy prefix template must not become a readable handler",
))
}
}
fn spawn_modern_template_http_server() -> (HttpServerFixture, Arc<AtomicUsize>) {
let reads = Arc::new(AtomicUsize::new(0));
let handler_reads = Arc::clone(&reads);
(
spawn_modern_resource_http_server(
"facade-http-template",
PublicHttpTemplatedResource {
reads: handler_reads,
},
),
reads,
)
}
fn spawn_modern_lossy_template_http_server() -> HttpServerFixture {
spawn_modern_resource_http_server("lossy-template", PublicHttpLossyTemplatedResource)
}
fn spawn_modern_resource_http_server<H: ResourceHandler + 'static>(
name: &'static str,
handler: H,
) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("template HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new(name, "1.0.0")
.resource(handler)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("template facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("template facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("template HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("template facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("template facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("template facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("template facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_resource_template_lists_and_reads_matched_uri() {
let cx = Cx::for_request();
let lossy_server = spawn_modern_lossy_template_http_server();
let mut lossy_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-lossy-template", "1.0.0")
.connect_http_with_cx(public_http_target(lossy_server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade still connects when a lossy template is refused");
let lossy_listed =
runtime_block_on_bounded(&cx, lossy_client.list_resource_templates(&cx, None))
.expect("refusing a lossy template must still serve resources/templates/list");
assert!(
lossy_listed.resource_templates.is_empty(),
"a lossy prefix modifier must not be advertised as a reverse-matchable template: {:?}",
lossy_listed.resource_templates
);
let lossy_read = runtime_block_on_bounded(
&cx,
lossy_client.read_resource(&cx, "test://public-http-e2e/item/alp"),
)
.expect_err("a dropped lossy template must not guess a three-character prefix match");
assert!(
matches!(
lossy_read,
modern::HttpClientError::CoreResult(ref error)
if error.code == McpErrorCode::InvalidParams
),
"the unregistered lossy template must stay InvalidParams: {lossy_read:?}"
);
drop(lossy_client);
lossy_server.shutdown();
let (server, reads) = spawn_modern_template_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-template", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects before template expansion");
let listed = runtime_block_on_bounded(&cx, client.list_resource_templates(&cx, None))
.expect("live bind_http must list the registered RFC 6570 template");
assert!(
listed
.resource_templates
.iter()
.any(|template| template.uri_template == PUBLIC_HTTP_TEMPLATE
&& template.name == PUBLIC_HTTP_TEMPLATE_NAME),
"resources/templates/list must retain the reversible template: {:?}",
listed.resource_templates
);
let matched = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_TEMPLATE_MATCHED_URI),
)
.expect("resources/read must expand a URI that matches the registered template");
assert!(
matches!(
matched.contents.as_slice(),
[EmbeddedResourceContents::Text { text, .. }] if text == "item:alpha"
),
"the matched template read must retain the extracted id: {:?}",
matched.contents
);
assert_eq!(
reads.load(Ordering::SeqCst),
1,
"a matching resources/read must invoke the templated handler once"
);
let unmatched = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_TEMPLATE_UNMATCHED_URI),
)
.expect_err("changing only the path that the template cannot bind must refuse before dispatch");
assert!(
matches!(
unmatched,
modern::HttpClientError::CoreResult(ref error)
if error.code == McpErrorCode::InvalidParams
),
"an unmatched template URI must stay InvalidParams: {unmatched:?}"
);
assert_eq!(
reads.load(Ordering::SeqCst),
1,
"the unmatched URI must leave the templated handler uninvoked"
);
drop(client);
server.shutdown();
}
const PUBLIC_HTTP_WATCH_RESOURCE_URI: &str = "test://public-http-e2e/watched";
const PUBLIC_HTTP_TOUCH_TOOL_NAME: &str = "public-http-e2e-touch";
/// Live modern HTTP resource whose update events are published to listeners.
struct PublicHttpWatchResource;
impl ResourceHandler for PublicHttpWatchResource {
fn definition(&self) -> Resource {
Resource {
uri: PUBLIC_HTTP_WATCH_RESOURCE_URI.to_owned(),
name: "public-http-e2e-watched".to_owned(),
description: Some("Proves live facade HTTP resources/updated listen".to_owned()),
mime_type: Some("text/plain".to_owned()),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
Ok(vec![ResourceContent {
uri: PUBLIC_HTTP_WATCH_RESOURCE_URI.to_owned(),
mime_type: None,
text: Some("watched".to_owned()),
blob: None,
}])
}
}
/// Live modern HTTP tool that publishes `notifications/resources/updated`.
struct PublicHttpTouchTool;
impl ToolHandler for PublicHttpTouchTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_TOUCH_TOOL_NAME.to_owned(),
description: Some("Proves live facade HTTP resource-update publication".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
let delivered = ctx.notify_resource_updated(PUBLIC_HTTP_WATCH_RESOURCE_URI);
Ok(vec![Content::text(if delivered {
"notified"
} else {
"silent"
})])
}
}
const PUBLIC_HTTP_HIDE_TOOL_NAME: &str = "public-http-e2e-hide";
/// Live modern HTTP tool that disables a peer tool and publishes `tools/list_changed`.
struct PublicHttpHideTool;
impl ToolHandler for PublicHttpHideTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_HIDE_TOOL_NAME.to_owned(),
description: Some("Proves live facade HTTP tools/list_changed publication".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
let delivered = ctx.disable_tool(PUBLIC_HTTP_TOUCH_TOOL_NAME);
Ok(vec![Content::text(if delivered {
"hidden"
} else {
"silent"
})])
}
}
const PUBLIC_HTTP_SHOW_TOOL_NAME: &str = "public-http-e2e-show";
/// Live modern HTTP tool that restores a peer tool after `disable_tool`.
struct PublicHttpShowTool;
impl ToolHandler for PublicHttpShowTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_SHOW_TOOL_NAME.to_owned(),
description: Some("Proves live facade HTTP enable_tool restoration".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
let shown = ctx.enable_tool(PUBLIC_HTTP_TOUCH_TOOL_NAME);
Ok(vec![Content::text(if shown { "shown" } else { "silent" })])
}
}
const PUBLIC_HTTP_TOGGLE_TOOL_NAME: &str = "public-http-e2e-toggle";
const PUBLIC_HTTP_CATALOG_PROMPT_NAME: &str = "public-http-e2e-catalog-prompt";
const PUBLIC_HTTP_HIDE_CATALOG_TOOL_NAME: &str = "public-http-e2e-hide-catalog";
/// Live modern HTTP tool that disables then re-enables a peer in one request.
struct PublicHttpToggleTool;
impl ToolHandler for PublicHttpToggleTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_TOGGLE_TOOL_NAME.to_owned(),
description: Some(
"Proves live facade HTTP enable_tool list_changed publication".to_owned(),
),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
let hidden = ctx.disable_tool(PUBLIC_HTTP_TOUCH_TOOL_NAME);
let shown = ctx.enable_tool(PUBLIC_HTTP_TOUCH_TOOL_NAME);
Ok(vec![Content::text(if hidden && shown {
"toggled"
} else {
"stuck"
})])
}
}
/// Live modern HTTP prompt used as a catalog-mutation subject.
struct PublicHttpCatalogPrompt;
impl PromptHandler for PublicHttpCatalogPrompt {
fn definition(&self) -> Prompt {
Prompt {
name: PUBLIC_HTTP_CATALOG_PROMPT_NAME.to_owned(),
description: Some(
"Proves live facade HTTP prompts/list_changed publication".to_owned(),
),
arguments: Vec::new(),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn get(
&self,
_ctx: &McpContext,
_arguments: HashMap<String, String>,
) -> McpResult<Vec<PromptMessage>> {
Ok(vec![PromptMessage {
role: Role::User,
content: Content::text("catalog"),
}])
}
}
/// Live modern HTTP tool that disables a resource and a prompt.
struct PublicHttpHideCatalogTool;
impl ToolHandler for PublicHttpHideCatalogTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_HIDE_CATALOG_TOOL_NAME.to_owned(),
description: Some(
"Proves live facade HTTP resource and prompt list_changed publication".to_owned(),
),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
let resource = ctx.disable_resource(PUBLIC_HTTP_WATCH_RESOURCE_URI);
let prompt = ctx.disable_prompt(PUBLIC_HTTP_CATALOG_PROMPT_NAME);
Ok(vec![Content::text(if resource && prompt {
"hidden"
} else {
"silent"
})])
}
}
const PUBLIC_HTTP_SHOW_CATALOG_TOOL_NAME: &str = "public-http-e2e-show-catalog";
/// Live HTTP tool that restores a peer resource and prompt after hide_catalog.
struct PublicHttpShowCatalogTool;
impl ToolHandler for PublicHttpShowCatalogTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_SHOW_CATALOG_TOOL_NAME.to_owned(),
description: Some(
"Proves live facade HTTP enable_resource and enable_prompt restoration".to_owned(),
),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
let resource = ctx.enable_resource(PUBLIC_HTTP_WATCH_RESOURCE_URI);
let prompt = ctx.enable_prompt(PUBLIC_HTTP_CATALOG_PROMPT_NAME);
Ok(vec![Content::text(if resource && prompt {
"shown"
} else {
"silent"
})])
}
}
fn spawn_modern_subscription_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("subscription HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-subscription", "1.0.0")
.resource(PublicHttpWatchResource)
.prompt(PublicHttpCatalogPrompt)
.tool(PublicHttpTouchTool)
.tool(PublicHttpHideTool)
.tool(PublicHttpToggleTool)
.tool(PublicHttpHideCatalogTool)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("subscription facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("subscription facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("subscription HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("subscription facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("subscription facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("subscription facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("subscription facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_resource_updated_is_retained_on_incremental_listen() {
let cx = Cx::for_request();
let server = spawn_modern_subscription_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-listen", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects before subscriptions/listen");
let limits = modern::SseLimits::new(64 * 1024, 2 * 1024 * 1024, 256)
.expect("subscription SSE limits must be nonzero");
let filter = modern::SubscriptionFilter {
resource_subscriptions: Some(vec![PUBLIC_HTTP_WATCH_RESOURCE_URI.to_owned()]),
..modern::SubscriptionFilter::default()
};
runtime_block_on_bounded(
&cx,
client.start_subscriptions_listener(&cx, filter, limits),
)
.expect("live bind_http must admit an incremental subscriptions/listen");
let acknowledgement = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("subscriptions/listen must emit its acknowledgement");
assert!(
matches!(
acknowledgement,
Some(modern::ModernHttpSubscriptionListenEvent::Acknowledged { .. })
),
"the first incremental listen record must be the accepted filter: {acknowledgement:?}"
);
let touched = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_TOUCH_TOOL_NAME, json!({})),
)
.expect("touching the watched resource must complete");
assert!(
touched.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "notified",
_ => false,
}),
"a matching incremental listener must count as notify_resource_updated delivery: {touched:?}"
);
let updated = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("subscriptions/listen must retain resources/updated after the handler publish");
assert!(
matches!(
updated,
Some(modern::ModernHttpSubscriptionListenEvent::Notification(
modern::ServerNotification::ResourceUpdated(ref params)
)) if params.uri.as_str() == PUBLIC_HTTP_WATCH_RESOURCE_URI
),
"live bind_http must retain notifications/resources/updated on the incremental listener: {updated:?}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_tools_list_changed_is_retained_on_incremental_listen() {
let cx = Cx::for_request();
let server = spawn_modern_subscription_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-list-changed", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects before subscriptions/listen");
let limits = modern::SseLimits::new(64 * 1024, 2 * 1024 * 1024, 256)
.expect("subscription SSE limits must be nonzero");
let filter = modern::SubscriptionFilter {
tools_list_changed: Some(true),
..modern::SubscriptionFilter::default()
};
runtime_block_on_bounded(
&cx,
client.start_subscriptions_listener(&cx, filter, limits),
)
.expect("live bind_http must admit an incremental subscriptions/listen");
let acknowledgement = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("subscriptions/listen must emit its acknowledgement");
assert!(
matches!(
acknowledgement,
Some(modern::ModernHttpSubscriptionListenEvent::Acknowledged { .. })
),
"the first incremental listen record must be the accepted filter: {acknowledgement:?}"
);
let hidden = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_HIDE_TOOL_NAME, json!({})),
)
.expect("disabling a peer tool must complete");
assert!(
hidden.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "hidden",
_ => false,
}),
"request-local SessionState must let disable_tool publish list_changed: {hidden:?}"
);
let changed = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("subscriptions/listen must retain tools/list_changed after the handler mutation");
assert!(
matches!(
changed,
Some(modern::ModernHttpSubscriptionListenEvent::Notification(
modern::ServerNotification::ToolsListChanged(_)
))
),
"live bind_http must retain notifications/tools/list_changed on the incremental listener: {changed:?}"
);
let toggled = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_TOGGLE_TOOL_NAME, json!({})),
)
.expect("disable then enable in one request must complete");
assert!(
toggled.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "toggled",
_ => false,
}),
"request-local SessionState must let enable_tool reverse the same-request disable: {toggled:?}"
);
let first_toggle = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("disable inside the toggle must publish tools/list_changed");
let second_toggle = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("enable_tool must publish a second tools/list_changed");
assert!(
matches!(
first_toggle,
Some(modern::ModernHttpSubscriptionListenEvent::Notification(
modern::ServerNotification::ToolsListChanged(_)
))
),
"the toggle disable must publish tools/list_changed: {first_toggle:?}"
);
assert!(
matches!(
second_toggle,
Some(modern::ModernHttpSubscriptionListenEvent::Notification(
modern::ServerNotification::ToolsListChanged(_)
))
),
"live bind_http must retain enable_tool notifications/tools/list_changed: {second_toggle:?}"
);
runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("a later POST on the same HTTP client must still list tools");
drop(client);
let mut other_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-list-changed-other", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("a second ModernOnly facade client connects after the first disable");
let other_tools = runtime_block_on_bounded(&cx, other_client.list_tools(&cx, None))
.expect("a later POST from another HTTP client must still list tools");
assert!(
other_tools
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOUCH_TOOL_NAME),
"disable_tool must not invent a process-wide HTTP session: {other_tools:?}"
);
drop(other_client);
server.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_proxy_client_catalog_listen_retains_list_changed() {
let server = spawn_modern_subscription_http_server();
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("proxy catalog listen client reactor initializes"),
)
.build()
.expect("proxy catalog listen client installs an owned runtime");
let outcome = runtime.block_on(async {
let cx = Cx::current().expect("owned proxy client runtime installs an ambient context");
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(server.address(), "/mcp")),
None,
None,
"e2e-http-proxy-listen".to_owned(),
"e2e-http-proxy-listen".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.map_err(|error| format!("proxy catalog listen HTTP plan failed: {error}"))?;
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-listen-upstream",
"native-h1:e2e-listen-upstream",
1,
plan,
ClientInfo {
name: "e2e-http-proxy-listen".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.map_err(|error| format!("live HTTP ProxyClient connect failed: {error}"))?;
let started = proxy
.start_catalog_listener(modern::SubscriptionFilter {
tools_list_changed: Some(true),
..modern::SubscriptionFilter::default()
})
.map_err(|error| format!("ProxyClient catalog listen start failed: {error}"))?;
if !started {
return Err(
"a live ModernHttp ProxyClient must own the incremental catalog listener"
.to_owned(),
);
}
let acknowledgement = proxy
.next_catalog_listener_event(&cx, &McpRequestCancellation::new())
.map_err(|error| format!("ProxyClient catalog listen ack failed: {error}"))?;
if !matches!(
acknowledgement,
StdioSubscriptionEvent::Acknowledged(ref filter)
if filter.tools_list_changed == Some(true)
) {
return Err(format!(
"the first ProxyClient listen record must be the accepted filter: {acknowledgement:?}"
));
}
let touched = proxy
.call_tool_typed(
&McpContext::new(cx.clone(), 1),
PUBLIC_HTTP_TOUCH_TOOL_NAME,
json!({}),
)
.map_err(|error| format!("ProxyClient touch tool failed: {error}"))?;
let CoreResult::Final(FinalCoreResult::ToolsCall { result: touched, .. }) = touched else {
return Err(format!(
"the live HTTP ProxyClient must keep a final tools/call result: {touched:?}"
));
};
if !touched.payload.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "notified" || text == "silent",
_ => false,
}) {
return Err(format!(
"the live HTTP ProxyClient must forward the non-mutating touch tool: {touched:?}"
));
}
let hidden = proxy
.call_tool_typed(
&McpContext::new(cx.clone(), 2),
PUBLIC_HTTP_HIDE_TOOL_NAME,
json!({}),
)
.map_err(|error| format!("ProxyClient hide tool failed: {error}"))?;
let CoreResult::Final(FinalCoreResult::ToolsCall { result: hidden, .. }) = hidden else {
return Err(format!(
"the live HTTP ProxyClient must keep a final hide tools/call result: {hidden:?}"
));
};
if !hidden.payload.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "hidden",
_ => false,
}) {
return Err(format!(
"the live HTTP ProxyClient must forward disable_tool: {hidden:?}"
));
}
let changed = proxy
.next_catalog_listener_event(&cx, &McpRequestCancellation::new())
.map_err(|error| {
format!("ProxyClient catalog listen must retain tools/list_changed: {error}")
})?;
if !matches!(
changed,
StdioSubscriptionEvent::Notification(modern::ServerNotification::ToolsListChanged(_))
) {
return Err(format!(
"live ProxyClient must retain notifications/tools/list_changed: {changed:?}"
));
}
Ok(())
});
server.shutdown();
outcome.expect("the live HTTP ProxyClient catalog listener must retain tools/list_changed");
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_proxy_client_tasks_listen_retains_status_and_catalog_listen_refuses_task_ids() {
let server = spawn_modern_task_http_server();
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("proxy Tasks listen client reactor initializes"),
)
.build()
.expect("proxy Tasks listen client installs an owned runtime");
let outcome = runtime.block_on(async {
let cx = Cx::current().expect("owned proxy Tasks listen runtime installs an ambient context");
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(server.address(), "/mcp")),
None,
None,
"e2e-http-proxy-tasks-listen".to_owned(),
"e2e-http-proxy-tasks-listen".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.map_err(|error| format!("proxy Tasks listen HTTP plan failed: {error}"))?;
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-tasks-listen-upstream",
"native-h1:e2e-tasks-listen-upstream",
1,
plan,
ClientInfo {
name: "e2e-http-proxy-tasks-listen".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.map_err(|error| format!("live HTTP ProxyClient Tasks listen connect failed: {error}"))?;
let mut creator = modern::ClientBuilder::new()
.client_info("e2e-http-proxy-tasks-listen-creator", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx)
.await
.map_err(|error| format!("public HTTP creator connect failed: {error}"))?;
let created = creator
.call_tool_outcome(
&cx,
RequestId::Number(2),
PUBLIC_HTTP_TASK_TOOL_NAME,
json!({}),
1 << 20,
)
.await
.map_err(|error| format!("live HTTP creator must create one official Task: {error}"))?;
let FinalToolCallOutcome::Task(created) = created else {
return Err(format!(
"the task-capable live tool must return the official Task result branch: {created:?}"
));
};
let task_id = created.task.base().task_id.clone();
let mut catalog_with_tasks = modern::SubscriptionFilter {
tools_list_changed: Some(true),
..modern::SubscriptionFilter::default()
};
modern::set_task_subscription_ids(&mut catalog_with_tasks, vec![task_id.clone()])
.map_err(|error| format!("the public Tasks filter must compose: {error}"))?;
let catalog_refusal = proxy
.start_catalog_listener(catalog_with_tasks)
.expect_err("catalog listen must refuse taskIds");
if !(catalog_refusal
.to_string()
.contains("start_final_task_listener")
|| catalog_refusal.to_string().contains("taskIds"))
{
return Err(format!(
"changing only the added taskIds must keep catalog listen refused: {catalog_refusal:?}"
));
}
let mut filter = modern::SubscriptionFilter::default();
modern::set_task_subscription_ids(&mut filter, vec![task_id.clone()])
.map_err(|error| format!("the public Tasks filter is invalid: {error}"))?;
let started = proxy
.start_final_task_listener(filter)
.map_err(|error| format!("ProxyClient official Tasks listen start failed: {error}"))?;
if !started {
return Err(
"a live ModernHttp ProxyClient must own the incremental official Tasks listener"
.to_owned(),
);
}
let acknowledgement = proxy
.next_final_task_listener_event(&cx, &McpRequestCancellation::new())
.map_err(|error| format!("ProxyClient official Tasks listen ack failed: {error}"))?;
if !matches!(
acknowledgement,
modern::StdioTaskSubscriptionEvent::Acknowledged(ref accepted)
if modern::task_subscription_ids(accepted)
.expect("acknowledged Tasks filter stays valid")
.as_deref()
== Some([task_id.clone()].as_slice())
) {
return Err(format!(
"the first incremental Tasks listen record must be the accepted taskIds: {acknowledgement:?}"
));
}
creator
.cancel_task(&cx, RequestId::Number(3), task_id.clone(), 1 << 20)
.await
.map_err(|error| format!("typed tasks/cancel failed: {error}"))?;
let notification_deadline = Instant::now() + HTTP_OPERATION_BOUND;
loop {
let event = proxy
.next_final_task_listener_event(&cx, &McpRequestCancellation::new())
.map_err(|error| {
format!("official Tasks listen must retain later status updates: {error}")
})?;
match event {
modern::StdioTaskSubscriptionEvent::Notification(notification)
if matches!(notification.params.task, FinalTask::Cancelled(_)) =>
{
if notification.params.task.base().task_id != task_id {
return Err(format!(
"the Tasks notification must keep the created id: {notification:?}"
));
}
break;
}
modern::StdioTaskSubscriptionEvent::Notification(_)
| modern::StdioTaskSubscriptionEvent::Acknowledged(_) => {
if Instant::now() >= notification_deadline {
return Err(
"the caller-owned supervisor publishes cancellation within the public bound"
.to_owned(),
);
}
}
modern::StdioTaskSubscriptionEvent::Terminal => {
return Err(
"the live Tasks listener must retain cancellation before terminal".to_owned(),
);
}
}
}
let observed = creator
.get_task(&cx, RequestId::Number(4), task_id, 1 << 20)
.await
.map_err(|error| {
format!("typed tasks/get remains usable after listen observed cancel: {error}")
})?;
if !matches!(observed.task, FinalTask::Cancelled(_)) {
return Err(format!(
"the same server must still admit tasks/get after listen: {observed:?}"
));
}
Ok(())
});
server.shutdown();
outcome.expect(
"the live HTTP ProxyClient official Tasks listener must retain Cancelled after tasks/cancel",
);
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_tasks_listen_retains_status_and_catalog_listen_refuses_task_ids() {
let cx = Cx::for_request();
let server = spawn_modern_task_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-tasks-listen", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live official Tasks HTTP server");
let created = runtime_block_on_bounded(
&cx,
client.call_tool_outcome(
&cx,
RequestId::Number(2),
PUBLIC_HTTP_TASK_TOOL_NAME,
json!({}),
1 << 20,
),
)
.expect("live bind_http must create one official Task");
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the task-capable live tool must return the official Task result branch: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
let limits = modern::SseLimits::new(64 * 1024, 2 * 1024 * 1024, 256)
.expect("subscription SSE limits must be nonzero");
let mut catalog_with_tasks = modern::SubscriptionFilter {
tools_list_changed: Some(true),
..modern::SubscriptionFilter::default()
};
modern::set_task_subscription_ids(&mut catalog_with_tasks, vec![task_id.clone()])
.expect("the public Tasks filter must compose");
let catalog_refusal = runtime_block_on_bounded(
&cx,
client.start_subscriptions_listener(&cx, catalog_with_tasks, limits),
)
.expect_err("catalog listen must refuse taskIds");
assert!(
catalog_refusal
.to_string()
.contains("open_final_task_subscription_listener")
|| catalog_refusal.to_string().contains("taskIds"),
"changing only the added taskIds must keep catalog listen refused: {catalog_refusal:?}"
);
let mut filter = modern::SubscriptionFilter::default();
modern::set_task_subscription_ids(&mut filter, vec![task_id.clone()])
.expect("the public Tasks filter is valid");
runtime_block_on_bounded(
&cx,
client.open_final_task_subscription_listener(&cx, filter, limits),
)
.expect("live bind_http must admit an incremental official Tasks listener");
let acknowledgement =
runtime_block_on_bounded(&cx, client.next_final_task_subscription_event(&cx))
.expect("official Tasks listen must emit its acknowledgement");
assert!(
matches!(
acknowledgement,
modern::StdioTaskSubscriptionEvent::Acknowledged(ref accepted)
if modern::task_subscription_ids(accepted)
.expect("acknowledged Tasks filter stays valid")
.as_deref()
== Some([task_id.clone()].as_slice())
),
"the first incremental Tasks listen record must be the accepted taskIds: {acknowledgement:?}"
);
runtime_block_on_bounded(
&cx,
client.cancel_task(&cx, RequestId::Number(3), task_id.clone(), 1 << 20),
)
.expect("typed tasks/cancel remains usable while the Tasks listener is live");
let notification_deadline = Instant::now() + HTTP_OPERATION_BOUND;
loop {
let event = runtime_block_on_bounded(&cx, client.next_final_task_subscription_event(&cx))
.expect("official Tasks listen must retain later status updates");
match event {
modern::StdioTaskSubscriptionEvent::Notification(notification)
if matches!(notification.params.task, FinalTask::Cancelled(_)) =>
{
assert_eq!(
notification.params.task.base().task_id,
task_id,
"the Tasks notification must keep the created id: {notification:?}"
);
break;
}
modern::StdioTaskSubscriptionEvent::Notification(_)
| modern::StdioTaskSubscriptionEvent::Acknowledged(_) => {
assert!(
Instant::now() < notification_deadline,
"the caller-owned supervisor publishes cancellation within the public bound"
);
}
modern::StdioTaskSubscriptionEvent::Terminal => {
panic!("the live Tasks listener must retain cancellation before terminal")
}
}
}
let observed = runtime_block_on_bounded(
&cx,
client.get_task(&cx, RequestId::Number(4), task_id, 1 << 20),
)
.expect("typed tasks/get remains usable after listen observed cancel");
assert!(
matches!(observed.task, FinalTask::Cancelled(_)),
"the same HTTP client must still admit tasks/get after listen: {observed:?}"
);
drop(client);
server.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_catalog_and_tasks_listen_stay_live_on_the_same_client() {
let cx = Cx::for_request();
let server = spawn_modern_task_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-dual-listen", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live official Tasks HTTP server");
let created = runtime_block_on_bounded(
&cx,
client.call_tool_outcome(
&cx,
RequestId::Number(2),
PUBLIC_HTTP_TASK_TOOL_NAME,
json!({}),
1 << 20,
),
)
.expect("live bind_http must create one official Task");
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the task-capable live tool must return the official Task result branch: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
let limits = modern::SseLimits::new(64 * 1024, 2 * 1024 * 1024, 256)
.expect("subscription SSE limits must be nonzero");
let catalog_filter = modern::SubscriptionFilter {
tools_list_changed: Some(true),
..modern::SubscriptionFilter::default()
};
runtime_block_on_bounded(
&cx,
client.start_subscriptions_listener(&cx, catalog_filter, limits),
)
.expect("live bind_http must admit a catalog listener while official Tasks remain unused");
let catalog_ack = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("catalog listen must emit its acknowledgement");
assert!(
matches!(
catalog_ack,
Some(modern::ModernHttpSubscriptionListenEvent::Acknowledged { .. })
),
"the first catalog listen record must be the accepted filter: {catalog_ack:?}"
);
let mut task_filter = modern::SubscriptionFilter::default();
modern::set_task_subscription_ids(&mut task_filter, vec![task_id.clone()])
.expect("the public Tasks filter is valid");
runtime_block_on_bounded(
&cx,
client.open_final_task_subscription_listener(&cx, task_filter, limits),
)
.expect(
"the same HttpClient must admit an official Tasks listener while catalog listen is live",
);
let task_ack = runtime_block_on_bounded(&cx, client.next_final_task_subscription_event(&cx))
.expect("official Tasks listen must emit its acknowledgement");
assert!(
matches!(
task_ack,
modern::StdioTaskSubscriptionEvent::Acknowledged(ref accepted)
if modern::task_subscription_ids(accepted)
.expect("acknowledged Tasks filter stays valid")
.as_deref()
== Some([task_id.clone()].as_slice())
),
"the first incremental Tasks listen record must be the accepted taskIds: {task_ack:?}"
);
let hidden = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_HIDE_TOOL_NAME, json!({})),
)
.expect("disabling a peer tool must complete while both listeners are live");
assert!(
hidden.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "hidden",
_ => false,
}),
"the same HttpClient must still admit tools/call while both listeners are live: {hidden:?}"
);
let changed = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("catalog listen must retain tools/list_changed while Tasks listen is live");
assert!(
matches!(
changed,
Some(modern::ModernHttpSubscriptionListenEvent::Notification(
modern::ServerNotification::ToolsListChanged(_)
))
),
"live bind_http must retain catalog list_changed on the same client as Tasks listen: {changed:?}"
);
runtime_block_on_bounded(
&cx,
client.cancel_task(&cx, RequestId::Number(3), task_id.clone(), 1 << 20),
)
.expect("typed tasks/cancel remains usable while both listeners are live");
let notification_deadline = Instant::now() + HTTP_OPERATION_BOUND;
loop {
let event = runtime_block_on_bounded(&cx, client.next_final_task_subscription_event(&cx))
.expect("official Tasks listen must retain later status updates while catalog listen is live");
match event {
modern::StdioTaskSubscriptionEvent::Notification(notification)
if matches!(notification.params.task, FinalTask::Cancelled(_)) =>
{
assert_eq!(
notification.params.task.base().task_id,
task_id,
"the Tasks notification must keep the created id: {notification:?}"
);
break;
}
modern::StdioTaskSubscriptionEvent::Notification(_)
| modern::StdioTaskSubscriptionEvent::Acknowledged(_) => {
assert!(
Instant::now() < notification_deadline,
"the caller-owned supervisor publishes cancellation within the public bound"
);
}
modern::StdioTaskSubscriptionEvent::Terminal => {
panic!("the live Tasks listener must retain cancellation before terminal")
}
}
}
let observed = runtime_block_on_bounded(
&cx,
client.get_task(&cx, RequestId::Number(4), task_id, 1 << 20),
)
.expect("typed tasks/get remains usable after both listeners observed their events");
assert!(
matches!(observed.task, FinalTask::Cancelled(_)),
"the same HTTP client must still admit tasks/get after dual listen: {observed:?}"
);
drop(client);
server.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_tasks_listen_retains_status_through_the_gateway() {
let upstream = spawn_modern_task_http_server();
let gateway = spawn_modern_http_task_proxy_gateway(upstream.address());
let cx = Cx::for_request();
let mut creator = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-tasks-listen-creator", "1.0.0")
.connect_http_with_cx(public_http_target(upstream.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live Tasks upstream");
let created = runtime_block_on_bounded(
&cx,
creator.call_tool_outcome(
&cx,
RequestId::Number(2),
PUBLIC_HTTP_TASK_TOOL_NAME,
json!({}),
1 << 20,
),
)
.expect("the live upstream must create one official Task");
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the task-capable live tool must return the official Task result branch: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
drop(creator);
let mut controller = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-tasks-listen-controller", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live as_proxy Tasks gateway");
let mut watcher = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-tasks-listen-watcher", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects a second as_proxy Tasks watch client");
let mut handle = runtime_block_on_bounded(&cx, watcher.attach_final_task(&cx, task_id.clone()))
.expect("as_proxy must attach the upstream-created Task for watch");
let mut watch = runtime_block_on_bounded(
&cx,
watcher.watch_final_task(
&cx,
&mut handle,
SseLimits::new(1_024, 8_192, 16).expect("bounded as_proxy Tasks SSE limits"),
),
)
.expect("as_proxy must open one live official Tasks subscription");
assert!(
matches!(
runtime_block_on_bounded(&cx, watch.next_event(&cx))
.expect("as_proxy Tasks listen must emit its acknowledgement"),
Some(FinalTaskWatchEvent::Acknowledged { .. })
),
"the first as_proxy Tasks listen record must be the acknowledgement"
);
runtime_block_on_bounded(
&cx,
controller.cancel_task(&cx, RequestId::Number(3), task_id.clone(), 1 << 20),
)
.expect("the live as_proxy gateway must forward tasks/cancel");
let notification_deadline = Instant::now() + HTTP_OPERATION_BOUND;
loop {
let event = runtime_block_on_bounded(&cx, watch.next_event(&cx))
.expect("as_proxy Tasks listen must retain later status updates");
match event {
Some(FinalTaskWatchEvent::TaskUpdated(notification))
if matches!(notification.params.task, FinalTask::Cancelled(_)) =>
{
assert_eq!(
notification.params.task.base().task_id,
task_id,
"the as_proxy Tasks notification must keep the created id"
);
break;
}
Some(FinalTaskWatchEvent::TaskUpdated(_))
| Some(FinalTaskWatchEvent::Acknowledged { .. }) => {
assert!(
Instant::now() < notification_deadline,
"the as_proxy relay publishes cancellation within the public bound"
);
}
Some(FinalTaskWatchEvent::Terminal { .. }) | None => {
panic!("the live as_proxy Tasks listener must retain cancellation before terminal")
}
}
}
drop(watch);
drop(watcher);
let observed = runtime_block_on_bounded(
&cx,
controller.get_task(&cx, RequestId::Number(4), task_id, 1 << 20),
)
.expect("typed tasks/get remains usable after the gateway listener observed cancellation");
assert!(
matches!(observed.task, FinalTask::Cancelled(_)),
"the same gateway session must still admit tasks/get after listen: {observed:?}"
);
drop(controller);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_forwards_resource_updated_on_catalog_listen() {
let upstream = spawn_modern_task_http_server();
let gateway = spawn_modern_http_task_proxy_gateway(upstream.address());
let cx = Cx::for_request();
let mut silent = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-resource-updated-silent", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects a silent as_proxy catalog client");
let silent_touch = runtime_block_on_bounded(
&cx,
silent.call_tool(
&cx,
&format!("ext/{PUBLIC_HTTP_TOUCH_TOOL_NAME}"),
json!({}),
),
)
.expect("as_proxy touch still completes without a catalog listener");
assert!(
silent_touch.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "silent",
_ => false,
}),
"omitting only catalog listen must keep upstream notify_resource_updated silent: {silent_touch:?}"
);
let refused = runtime_block_on_bounded(
&cx,
silent.call_tool(&cx, PUBLIC_HTTP_TOUCH_TOOL_NAME, json!({})),
);
assert!(
refused.is_err(),
"changing only the unprefixed touch name must stay refused: {refused:?}"
);
drop(silent);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-resource-updated", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects the as_proxy catalog listener");
let limits = modern::SseLimits::new(64 * 1024, 2 * 1024 * 1024, 256)
.expect("subscription SSE limits must be nonzero");
let filter = modern::SubscriptionFilter {
resource_subscriptions: Some(vec![PUBLIC_HTTP_WATCH_RESOURCE_URI.to_owned()]),
..modern::SubscriptionFilter::default()
};
runtime_block_on_bounded(
&cx,
client.start_subscriptions_listener(&cx, filter, limits),
)
.expect("as_proxy must admit a catalog listener for the unprefixed resource URI");
let acknowledgement = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("as_proxy catalog listen must emit its acknowledgement");
assert!(
matches!(
acknowledgement,
Some(modern::ModernHttpSubscriptionListenEvent::Acknowledged { .. })
),
"the first as_proxy catalog listen record must be the accepted filter: {acknowledgement:?}"
);
let touched = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
&format!("ext/{PUBLIC_HTTP_TOUCH_TOOL_NAME}"),
json!({}),
),
)
.expect("as_proxy must forward the prefixed touch tool");
assert!(
touched.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "notified",
_ => false,
}),
"an inbound catalog listen must count as upstream notify_resource_updated delivery: {touched:?}"
);
let updated = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("as_proxy catalog listen must retain the relayed resources/updated");
assert!(
matches!(
updated,
Some(modern::ModernHttpSubscriptionListenEvent::Notification(
modern::ServerNotification::ResourceUpdated(ref params)
)) if params.uri.as_str() == PUBLIC_HTTP_WATCH_RESOURCE_URI
),
"as_proxy must relay notifications/resources/updated onto the inbound catalog listen: {updated:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_catalog_and_tasks_listen_stay_live_on_the_same_client() {
let upstream = spawn_modern_task_http_server();
let gateway = spawn_modern_http_task_proxy_gateway(upstream.address());
let cx = Cx::for_request();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-dual-listen", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live as_proxy dual-listen gateway");
let created = runtime_block_on_bounded(
&cx,
client.call_tool_outcome(
&cx,
RequestId::Number(2),
&format!("ext/{PUBLIC_HTTP_TASK_TOOL_NAME}"),
json!({}),
1 << 20,
),
)
.expect("as_proxy must create one official Task through the gateway");
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the prefixed task-capable tool must return the official Task result branch: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
let limits = modern::SseLimits::new(64 * 1024, 2 * 1024 * 1024, 256)
.expect("subscription SSE limits must be nonzero");
let catalog_filter = modern::SubscriptionFilter {
resource_subscriptions: Some(vec![PUBLIC_HTTP_WATCH_RESOURCE_URI.to_owned()]),
..modern::SubscriptionFilter::default()
};
runtime_block_on_bounded(
&cx,
client.start_subscriptions_listener(&cx, catalog_filter, limits),
)
.expect("as_proxy must admit a catalog listener while official Tasks remain unused");
let catalog_ack = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("as_proxy catalog listen must emit its acknowledgement");
assert!(
matches!(
catalog_ack,
Some(modern::ModernHttpSubscriptionListenEvent::Acknowledged { .. })
),
"the first as_proxy catalog listen record must be the accepted filter: {catalog_ack:?}"
);
let mut task_filter = modern::SubscriptionFilter::default();
modern::set_task_subscription_ids(&mut task_filter, vec![task_id.clone()])
.expect("the public Tasks filter is valid");
runtime_block_on_bounded(
&cx,
client.open_final_task_subscription_listener(&cx, task_filter, limits),
)
.expect("the same as_proxy HttpClient must admit official Tasks listen while catalog listen is live");
let task_ack = runtime_block_on_bounded(&cx, client.next_final_task_subscription_event(&cx))
.expect("as_proxy official Tasks listen must emit its acknowledgement");
assert!(
matches!(
task_ack,
modern::StdioTaskSubscriptionEvent::Acknowledged(ref accepted)
if modern::task_subscription_ids(accepted)
.expect("acknowledged Tasks filter stays valid")
.as_deref()
== Some([task_id.clone()].as_slice())
),
"the first as_proxy Tasks listen record must be the accepted taskIds: {task_ack:?}"
);
let touched = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
&format!("ext/{PUBLIC_HTTP_TOUCH_TOOL_NAME}"),
json!({}),
),
)
.expect("as_proxy touch must complete while both listeners are live");
assert!(
touched.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "notified",
_ => false,
}),
"an inbound catalog listen must count as upstream notify_resource_updated delivery: {touched:?}"
);
let updated = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("as_proxy catalog listen must retain resources/updated while Tasks listen is live");
assert!(
matches!(
updated,
Some(modern::ModernHttpSubscriptionListenEvent::Notification(
modern::ServerNotification::ResourceUpdated(ref params)
)) if params.uri.as_str() == PUBLIC_HTTP_WATCH_RESOURCE_URI
),
"as_proxy dual listen must retain resources/updated on the catalog listener: {updated:?}"
);
runtime_block_on_bounded(
&cx,
client.cancel_task(&cx, RequestId::Number(3), task_id.clone(), 1 << 20),
)
.expect("typed tasks/cancel remains usable while both as_proxy listeners are live");
let notification_deadline = Instant::now() + HTTP_OPERATION_BOUND;
loop {
let event = runtime_block_on_bounded(&cx, client.next_final_task_subscription_event(&cx))
.expect("as_proxy Tasks listen must retain later status updates while catalog listen is live");
match event {
modern::StdioTaskSubscriptionEvent::Notification(notification)
if matches!(notification.params.task, FinalTask::Cancelled(_)) =>
{
assert_eq!(
notification.params.task.base().task_id,
task_id,
"the as_proxy Tasks notification must keep the created id: {notification:?}"
);
break;
}
modern::StdioTaskSubscriptionEvent::Notification(_)
| modern::StdioTaskSubscriptionEvent::Acknowledged(_) => {
assert!(
Instant::now() < notification_deadline,
"the as_proxy relay publishes cancellation within the public bound"
);
}
modern::StdioTaskSubscriptionEvent::Terminal => {
panic!("the live as_proxy Tasks listener must retain cancellation before terminal")
}
}
}
let observed = runtime_block_on_bounded(
&cx,
client.get_task(&cx, RequestId::Number(4), task_id, 1 << 20),
)
.expect("typed tasks/get remains usable after both as_proxy listeners observed their events");
assert!(
matches!(observed.task, FinalTask::Cancelled(_)),
"the same as_proxy HTTP client must still admit tasks/get after dual listen: {observed:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_catalog_and_tasks_listen_stay_live_on_the_same_client() {
// Create through the gateway so the route-bound relay records the handle.
// The precreate spawn occupies echo's in-memory store (capacity 1).
let gateway = spawn_modern_http_stdio_as_proxy_gateway_without_precreated_task();
let cx = Cx::for_request();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-stdio-dual-listen", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy dual-listen gateway");
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("the live stdio as_proxy gateway must advertise the prefixed durable task");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == "ext/durable_task"),
"as_proxy must prefix the live stdio durable_task tool: {listed:?}"
);
let unprefixed = runtime_block_on_bounded(
&cx,
client.call_tool_outcome(
&cx,
RequestId::Number(2),
"durable_task",
json!({}),
1 << 20,
),
)
.expect_err("changing only the tool name must not create a Task on the unprefixed path");
let _ = unprefixed;
let created = runtime_block_on_bounded(
&cx,
client.call_tool_outcome(
&cx,
RequestId::Number(3),
"ext/durable_task",
json!({}),
1 << 20,
),
)
.expect("stdio as_proxy must create one official Task through the gateway");
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the prefixed durable_task tool must return the official Task result branch: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
let limits = modern::SseLimits::new(64 * 1024, 2 * 1024 * 1024, 256)
.expect("subscription SSE limits must be nonzero");
let catalog_filter = modern::SubscriptionFilter {
resource_subscriptions: Some(vec!["info://server".to_owned()]),
..modern::SubscriptionFilter::default()
};
runtime_block_on_bounded(
&cx,
client.start_subscriptions_listener(&cx, catalog_filter, limits),
)
.expect("stdio as_proxy must admit a catalog listener while official Tasks remain unused");
let catalog_ack = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("stdio as_proxy catalog listen must emit its acknowledgement");
assert!(
matches!(
catalog_ack,
Some(modern::ModernHttpSubscriptionListenEvent::Acknowledged { .. })
),
"the first stdio as_proxy catalog listen record must be the accepted filter: {catalog_ack:?}"
);
let mut task_filter = modern::SubscriptionFilter::default();
modern::set_task_subscription_ids(&mut task_filter, vec![task_id.clone()])
.expect("the public Tasks filter is valid");
runtime_block_on_bounded(
&cx,
client.open_final_task_subscription_listener(&cx, task_filter, limits),
)
.expect(
"the same stdio as_proxy HttpClient must admit official Tasks listen while catalog listen is live",
);
let task_ack = runtime_block_on_bounded(&cx, client.next_final_task_subscription_event(&cx))
.expect("stdio as_proxy official Tasks listen must emit its acknowledgement");
assert!(
matches!(
task_ack,
modern::StdioTaskSubscriptionEvent::Acknowledged(ref accepted)
if modern::task_subscription_ids(accepted)
.expect("acknowledged Tasks filter stays valid")
.as_deref()
== Some([task_id.clone()].as_slice())
),
"the first stdio as_proxy Tasks listen record must be the accepted taskIds: {task_ack:?}"
);
let touched = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/touch_server_info", json!({})),
)
.expect("stdio as_proxy touch must complete while both listeners are live");
assert!(
touched.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "notified",
_ => false,
}),
"an inbound catalog listen must count as echo notify_resource_updated delivery: {touched:?}"
);
let updated = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx)).expect(
"stdio as_proxy catalog listen must retain resources/updated while Tasks listen is live",
);
assert!(
matches!(
updated,
Some(modern::ModernHttpSubscriptionListenEvent::Notification(
modern::ServerNotification::ResourceUpdated(ref params)
)) if params.uri.as_str() == "info://server"
),
"stdio as_proxy dual listen must retain resources/updated on the catalog listener: {updated:?}"
);
runtime_block_on_bounded(
&cx,
client.cancel_task(&cx, RequestId::Number(4), task_id.clone(), 1 << 20),
)
.expect("typed tasks/cancel remains usable while both stdio as_proxy listeners are live");
let notification_deadline = Instant::now() + HTTP_OPERATION_BOUND;
loop {
let event = runtime_block_on_bounded(&cx, client.next_final_task_subscription_event(&cx))
.expect(
"stdio as_proxy Tasks listen must retain later status updates while catalog listen is live",
);
match event {
modern::StdioTaskSubscriptionEvent::Notification(notification)
if matches!(notification.params.task, FinalTask::Cancelled(_)) =>
{
assert_eq!(
notification.params.task.base().task_id,
task_id,
"the stdio as_proxy Tasks notification must keep the created id: {notification:?}"
);
break;
}
modern::StdioTaskSubscriptionEvent::Notification(_)
| modern::StdioTaskSubscriptionEvent::Acknowledged(_) => {
assert!(
Instant::now() < notification_deadline,
"the stdio as_proxy relay publishes cancellation within the public bound"
);
}
modern::StdioTaskSubscriptionEvent::Terminal => {
panic!(
"the live stdio as_proxy Tasks listener must retain cancellation before terminal"
)
}
}
}
let observed = runtime_block_on_bounded(
&cx,
client.get_task(&cx, RequestId::Number(5), task_id, 1 << 20),
)
.expect(
"typed tasks/get remains usable after both stdio as_proxy listeners observed their events",
);
assert!(
matches!(observed.task, FinalTask::Cancelled(_)),
"the same stdio as_proxy HTTP client must still admit tasks/get after dual listen: {observed:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_as_proxy_stdio_forwards_resource_template_completion() {
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway();
let cx = Cx::for_request();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-stdio-template-complete", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy completion gateway");
let note = modern::CompletionParams {
reference: modern::CompletionReference::Resource {
uri: "note://{name}".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "name".to_owned(),
value: "al".to_owned(),
},
context: None,
};
let retained = runtime_block_on_bounded(&cx, client.complete(&cx, note.clone()))
.expect("stdio as_proxy must forward unprefixed note://{name} completion");
assert_eq!(
retained.completion.values,
vec!["alice".to_owned()],
"stdio as_proxy must retain the echo note-template completion: {retained:?}"
);
let mut memo = note;
memo.reference = modern::CompletionReference::Resource {
uri: "memo://{name}".to_owned(),
};
let missing = runtime_block_on_bounded(&cx, client.complete(&cx, memo))
.expect_err("changing only the template URI must not invent a completion provider");
let _ = missing;
let greeting = modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: "ext/greeting".to_owned(),
title: "Greeting".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "name".to_owned(),
value: "co".to_owned(),
},
context: Some(modern::FinalCompletionContext {
arguments: Some(std::collections::BTreeMap::from([(
"locale".to_owned(),
"en-US".to_owned(),
)])),
}),
};
let greeting = runtime_block_on_bounded(&cx, client.complete(&cx, greeting))
.expect("prefixed greeting complete must stay admitted after template complete");
assert!(
!greeting.completion.values.is_empty(),
"stdio as_proxy must still complete ext/greeting: {greeting:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_advertises_completions_when_echo_supports() {
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_configured(false, Vec::new());
let cx = Cx::for_request();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-stdio-complete-advertise", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy completion-advertise gateway");
let discovery = serde_json::to_value(client.server_discovery().capabilities())
.expect("stdio as_proxy discovery capabilities serialize");
assert_eq!(
discovery.get("completions"),
Some(&json!({})),
"stdio as_proxy must advertise completions when echo installed a provider: {discovery}"
);
let note = modern::CompletionParams {
reference: modern::CompletionReference::Resource {
uri: "note://{name}".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "name".to_owned(),
value: "al".to_owned(),
},
context: None,
};
let retained = runtime_block_on_bounded(&cx, client.complete(&cx, note))
.expect("advertised stdio as_proxy completions must still forward note://{name}");
assert_eq!(
retained.completion.values,
vec!["alice".to_owned()],
"stdio as_proxy must retain the echo note-template completion after advertising: {retained:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_omits_completions_when_echo_has_none() {
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_configured(
false,
vec![("FASTMCP_NO_COMPLETIONS".to_owned(), "1".to_owned())],
);
let cx = Cx::for_request();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-stdio-complete-omit", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy completion-omit gateway");
let discovery = serde_json::to_value(client.server_discovery().capabilities())
.expect("stdio as_proxy discovery capabilities serialize");
assert!(
discovery.get("completions").is_none(),
"changing only FASTMCP_NO_COMPLETIONS must omit stdio as_proxy completions: {discovery}"
);
let refused = runtime_block_on_bounded(
&cx,
client.complete(
&cx,
modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: "ext/greeting".to_owned(),
title: "Greeting".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "name".to_owned(),
value: "co".to_owned(),
},
context: None,
},
),
)
.expect_err("stdio as_proxy without an echo completion provider must refuse complete");
let _ = refused;
drop(client);
gateway.shutdown();
}
/// Live modern stdio `as_proxy("ext")` HTTP gateway against shipped echo with
/// `FASTMCP_FS_ROOT` so unprefixed `file:///e2e/{+path}` stays exact-final.
#[cfg(all(
unix,
feature = "proxy",
feature = "tasks",
any(target_os = "linux", target_os = "macos")
))]
fn spawn_modern_http_stdio_as_proxy_filesystem_gateway() -> HttpServerFixture {
let root = std::env::temp_dir().join(format!(
"fastmcp-public-http-stdio-as-proxy-fs-e2e-{}",
std::process::id()
));
std::fs::create_dir_all(&root)
.expect("the modern stdio as_proxy filesystem e2e root is created");
std::fs::write(
root.join(PUBLIC_HTTP_FS_FILE_NAME),
PUBLIC_HTTP_FS_FILE_TEXT,
)
.expect("the modern stdio as_proxy filesystem e2e file is written");
let root_path = root
.to_str()
.expect("the modern stdio as_proxy filesystem e2e root is utf-8")
.to_owned();
spawn_modern_http_stdio_as_proxy_gateway_configured(
false,
vec![
("FASTMCP_FS_ROOT".to_owned(), root_path),
(
"FASTMCP_FS_PREFIX".to_owned(),
PUBLIC_HTTP_FS_PREFIX.to_owned(),
),
],
)
.0
}
#[cfg(all(
unix,
feature = "proxy",
feature = "tasks",
any(target_os = "linux", target_os = "macos")
))]
#[test]
fn e2e_public_http_as_proxy_stdio_forwards_filesystem_template() {
let cx = Cx::for_request();
let gateway = spawn_modern_http_stdio_as_proxy_filesystem_gateway();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-stdio-filesystem", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy filesystem gateway");
let listed = runtime_block_on_bounded(&cx, client.list_resource_templates(&cx, None))
.expect("stdio as_proxy must list the unprefixed filesystem template");
assert!(
listed
.resource_templates
.iter()
.any(|template| template.uri_template == PUBLIC_HTTP_FS_TEMPLATE
&& template.name == PUBLIC_HTTP_FS_PREFIX),
"stdio as_proxy must keep the exact filesystem template: {:?}",
listed.resource_templates
);
assert!(
listed
.resource_templates
.iter()
.all(|template| !template.uri_template.starts_with("ext/")),
"a nonempty as_proxy prefix must not invent a non-absolute filesystem template URI: {:?}",
listed.resource_templates
);
let unmatched =
runtime_block_on_bounded(&cx, client.read_resource(&cx, "file:///other/note.txt"))
.expect_err("changing only the filesystem prefix must refuse before the echo handler");
let _ = unmatched;
let prefixed = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, &format!("ext/{PUBLIC_HTTP_FS_FILE_URI}")),
)
.expect_err("changing only the as_proxy prefix must not invent a filesystem URI");
let _ = prefixed;
let file = runtime_block_on_bounded(&cx, client.read_resource(&cx, PUBLIC_HTTP_FS_FILE_URI))
.expect("stdio as_proxy must expand the live file URI through the echo FilesystemProvider");
assert!(
matches!(
file.contents.as_slice(),
[EmbeddedResourceContents::Text { text, .. }] if text == PUBLIC_HTTP_FS_FILE_TEXT
),
"stdio as_proxy must retain the echo filesystem bytes: {:?}",
file.contents
);
drop(client);
gateway.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_as_proxy_forwards_resource_template_completion() {
let upstream = spawn_modern_template_completion_http_server();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let cx = Cx::for_request();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-template-complete", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy template-completion gateway");
let retained = runtime_block_on_bounded(
&cx,
client.complete(&cx, public_http_template_completion_params()),
)
.expect("as_proxy_typed must forward the unprefixed resource-template completion");
assert_eq!(
retained.completion.values,
vec![PUBLIC_HTTP_TEMPLATE_COMPLETION_VALUE.to_owned()],
"as_proxy_typed must retain the upstream template completion: {retained:?}"
);
let mut other = public_http_template_completion_params();
other.reference = modern::CompletionReference::Resource {
uri: PUBLIC_HTTP_OTHER_TEMPLATE.to_owned(),
};
let missing = runtime_block_on_bounded(&cx, client.complete(&cx, other))
.expect_err("changing only the template URI must not invent a completion provider");
let _ = missing;
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_advertises_completions_when_upstream_supports() {
let upstream = spawn_modern_template_completion_http_server();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let cx = Cx::for_request();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-complete-advertise", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy completion-advertise gateway");
let discovery = serde_json::to_value(client.server_discovery().capabilities())
.expect("as_proxy discovery capabilities serialize");
assert_eq!(
discovery.get("completions"),
Some(&json!({})),
"as_proxy must advertise completions when the upstream installed a provider: {discovery}"
);
let retained = runtime_block_on_bounded(
&cx,
client.complete(&cx, public_http_template_completion_params()),
)
.expect("advertised as_proxy completions must still forward the unprefixed template");
assert_eq!(
retained.completion.values,
vec![PUBLIC_HTTP_TEMPLATE_COMPLETION_VALUE.to_owned()],
"as_proxy must retain the upstream template completion after advertising: {retained:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_omits_completions_when_upstream_has_none() {
let upstream = spawn_modern_as_proxy_counting_upstream();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let cx = Cx::for_request();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-complete-omit", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy completion-omit gateway");
let discovery = serde_json::to_value(client.server_discovery().capabilities())
.expect("as_proxy discovery capabilities serialize");
assert!(
discovery.get("completions").is_none(),
"changing only the missing upstream completion provider must omit completions: {discovery}"
);
let prefixed = format!("ext/{PUBLIC_HTTP_PROMPT_NAME}");
let refused = runtime_block_on_bounded(
&cx,
client.complete(
&cx,
modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: prefixed,
title: "Public HTTP E2E Prompt".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "subject".to_owned(),
value: "al".to_owned(),
},
context: None,
},
),
)
.expect_err("an as_proxy gateway without a completion provider must refuse complete");
let _ = refused;
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_tasks_listen_retains_status_through_the_gateway() {
let (gateway, task_id) = spawn_modern_http_stdio_as_proxy_gateway();
let cx = Cx::for_request();
let mut controller = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-stdio-tasks-listen-controller", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy Tasks gateway");
let mut watcher = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-stdio-tasks-listen-watcher", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects a second stdio as_proxy Tasks watch client");
let mut handle = runtime_block_on_bounded(&cx, watcher.attach_final_task(&cx, task_id.clone()))
.expect("stdio as_proxy must attach the upstream-created Task for watch");
let mut watch = runtime_block_on_bounded(
&cx,
watcher.watch_final_task(
&cx,
&mut handle,
SseLimits::new(1_024, 8_192, 16).expect("bounded stdio as_proxy Tasks SSE limits"),
),
)
.expect("stdio as_proxy must open one live official Tasks subscription");
assert!(
matches!(
runtime_block_on_bounded(&cx, watch.next_event(&cx))
.expect("stdio as_proxy Tasks listen must emit its acknowledgement"),
Some(FinalTaskWatchEvent::Acknowledged { .. })
),
"the first stdio as_proxy Tasks listen record must be the acknowledgement"
);
runtime_block_on_bounded(
&cx,
controller.cancel_task(&cx, RequestId::Number(3), task_id.clone(), 1 << 20),
)
.expect("the live stdio as_proxy gateway must forward tasks/cancel");
let notification_deadline = Instant::now() + HTTP_OPERATION_BOUND;
loop {
let event = runtime_block_on_bounded(&cx, watch.next_event(&cx))
.expect("stdio as_proxy Tasks listen must retain later status updates");
match event {
Some(FinalTaskWatchEvent::TaskUpdated(notification))
if matches!(notification.params.task, FinalTask::Cancelled(_)) =>
{
assert_eq!(
notification.params.task.base().task_id,
task_id,
"the stdio as_proxy Tasks notification must keep the created id"
);
break;
}
Some(FinalTaskWatchEvent::TaskUpdated(_))
| Some(FinalTaskWatchEvent::Acknowledged { .. }) => {
assert!(
Instant::now() < notification_deadline,
"the stdio as_proxy relay publishes cancellation within the public bound"
);
}
Some(FinalTaskWatchEvent::Terminal { .. }) | None => {
panic!(
"the live stdio as_proxy Tasks listener must retain cancellation before terminal"
)
}
}
}
let observed = runtime_block_on_bounded(
&cx,
controller.get_task(&cx, RequestId::Number(4), task_id, 1 << 20),
)
.expect(
"typed tasks/get remains usable after the stdio gateway listener observed cancellation",
);
assert!(
matches!(observed.task, FinalTask::Cancelled(_)),
"the same stdio as_proxy session must still admit tasks/get after listen: {observed:?}"
);
drop(watch);
drop(watcher);
drop(controller);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_forwards_resource_updated_on_catalog_listen() {
let (gateway, _precreated) = spawn_modern_http_stdio_as_proxy_gateway();
let cx = Cx::for_request();
let mut silent = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-stdio-resource-updated-silent", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects a silent stdio as_proxy catalog client");
let silent_touch = runtime_block_on_bounded(
&cx,
silent.call_tool(&cx, "ext/touch_server_info", json!({})),
)
.expect("stdio as_proxy touch still completes without a catalog listener");
assert!(
silent_touch.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "silent",
_ => false,
}),
"omitting only catalog listen must keep echo notify_resource_updated silent: {silent_touch:?}"
);
let refused =
runtime_block_on_bounded(&cx, silent.call_tool(&cx, "touch_server_info", json!({})));
assert!(
refused.is_err(),
"changing only the unprefixed touch name must stay refused: {refused:?}"
);
drop(silent);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-stdio-resource-updated", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects the stdio as_proxy catalog listener");
let limits = modern::SseLimits::new(64 * 1024, 2 * 1024 * 1024, 256)
.expect("subscription SSE limits must be nonzero");
let filter = modern::SubscriptionFilter {
resource_subscriptions: Some(vec!["info://server".to_owned()]),
..modern::SubscriptionFilter::default()
};
runtime_block_on_bounded(
&cx,
client.start_subscriptions_listener(&cx, filter, limits),
)
.expect("stdio as_proxy must admit a catalog listener for info://server");
let acknowledgement = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("stdio as_proxy catalog listen must emit its acknowledgement");
assert!(
matches!(
acknowledgement,
Some(modern::ModernHttpSubscriptionListenEvent::Acknowledged { .. })
),
"the first stdio as_proxy catalog listen record must be the accepted filter: {acknowledgement:?}"
);
let touched = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/touch_server_info", json!({})),
)
.expect("stdio as_proxy must forward the prefixed touch tool");
assert!(
touched.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "notified",
_ => false,
}),
"an inbound catalog listen must count as echo notify_resource_updated delivery: {touched:?}"
);
let updated = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("stdio as_proxy catalog listen must retain the relayed resources/updated");
assert!(
matches!(
updated,
Some(modern::ModernHttpSubscriptionListenEvent::Notification(
modern::ServerNotification::ResourceUpdated(ref params)
)) if params.uri.as_str() == "info://server"
),
"stdio as_proxy must relay notifications/resources/updated onto the inbound catalog listen: {updated:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_creates_official_task_through_the_gateway() {
let cx = Cx::for_request();
let upstream = spawn_modern_task_http_server();
let gateway = spawn_modern_http_task_proxy_gateway(upstream.address());
let prefixed = format!("ext/{PUBLIC_HTTP_TASK_TOOL_NAME}");
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-tasks-create", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live as_proxy Tasks gateway");
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("the live as_proxy gateway must advertise the prefixed Tasks tool");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed),
"as_proxy_typed must prefix the live Tasks tool: {listed:?}"
);
assert!(
!listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TASK_TOOL_NAME),
"a nonempty as_proxy prefix must not keep the unprefixed Tasks tool: {listed:?}"
);
let unprefixed = runtime_block_on_bounded(
&cx,
client.call_tool_outcome(
&cx,
RequestId::Number(2),
PUBLIC_HTTP_TASK_TOOL_NAME,
json!({}),
1 << 20,
),
)
.expect_err("changing only the tool name must not create a Task on the unprefixed path");
let _ = unprefixed;
let created = runtime_block_on_bounded(
&cx,
client.call_tool_outcome(&cx, RequestId::Number(3), &prefixed, json!({}), 1 << 20),
)
.expect("the live as_proxy gateway must create one official Task through the prefixed tool");
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the prefixed task-capable live tool must return the official Task result branch: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
let observed = runtime_block_on_bounded(
&cx,
client.get_task(&cx, RequestId::Number(4), task_id.clone(), 1 << 20),
)
.expect("typed tasks/get must return the gateway-created Task");
assert_eq!(
observed.task.base().task_id,
task_id,
"as_proxy_typed must keep the same id it created: {observed:?}"
);
let missing = FinalTaskId::parse("missing-gateway-created-task")
.expect("the planted-missing task id is a valid official TaskId");
let missing_get = runtime_block_on_bounded(
&cx,
client.get_task(&cx, RequestId::Number(5), missing, 1 << 20),
)
.expect_err("changing only the task id must not invent a Task on the as_proxy gateway");
let _ = missing_get;
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_creates_official_task_with_progress_marker() {
let cx = Cx::for_request();
let upstream = spawn_modern_task_http_server();
let gateway = spawn_modern_http_task_proxy_gateway(upstream.address());
let prefixed = format!("ext/{PUBLIC_HTTP_TASK_TOOL_NAME}");
let marker = modern::ProgressMarker::from("as-proxy-task-create-progress");
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-tasks-create-progress", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live as_proxy Tasks gateway");
let unprefixed = runtime_block_on_bounded(
&cx,
client.call_tool_outcome_with_progress_marker(
&cx,
RequestId::Number(2),
PUBLIC_HTTP_TASK_TOOL_NAME,
json!({}),
marker.clone(),
1 << 20,
),
)
.expect_err(
"changing only the tool name must not create a Task on the unprefixed path even with a progress token",
);
let _ = unprefixed;
let created = runtime_block_on_bounded(
&cx,
client.call_tool_outcome_with_progress_marker(
&cx,
RequestId::Number(3),
&prefixed,
json!({}),
marker,
1 << 20,
),
)
.expect(
"a progress token must not prevent as_proxy from creating one official Task through the prefixed tool",
);
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the prefixed task-capable live tool must return the official Task result branch after a progress token: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
let observed = runtime_block_on_bounded(
&cx,
client.get_task(&cx, RequestId::Number(4), task_id.clone(), 1 << 20),
)
.expect("typed tasks/get must return the progress-token gateway-created Task");
assert_eq!(
observed.task.base().task_id,
task_id,
"as_proxy_typed must keep the same id it created under a progress token: {observed:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_creates_official_task_through_the_gateway() {
let cx = Cx::for_request();
let gateway = spawn_modern_http_stdio_as_proxy_gateway_without_precreated_task();
let prefixed = "ext/durable_task";
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-stdio-tasks-create", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy Tasks gateway");
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("the live stdio as_proxy gateway must advertise the prefixed durable task");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed),
"as_proxy must prefix the live stdio durable_task tool: {listed:?}"
);
assert!(
!listed.tools.iter().any(|tool| tool.name == "durable_task"),
"a nonempty as_proxy prefix must not keep the unprefixed durable_task tool: {listed:?}"
);
let unprefixed = runtime_block_on_bounded(
&cx,
client.call_tool_outcome(
&cx,
RequestId::Number(2),
"durable_task",
json!({}),
1 << 20,
),
)
.expect_err("changing only the tool name must not create a Task on the unprefixed path");
let _ = unprefixed;
let created = runtime_block_on_bounded(
&cx,
client.call_tool_outcome(&cx, RequestId::Number(3), prefixed, json!({}), 1 << 20),
)
.expect(
"the live stdio as_proxy gateway must create one official Task through ext/durable_task",
);
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the prefixed stdio durable_task must return the official Task result branch: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
let observed = runtime_block_on_bounded(
&cx,
client.get_task(&cx, RequestId::Number(4), task_id.clone(), 1 << 20),
)
.expect("typed tasks/get must return the stdio-gateway-created Task");
assert_eq!(
observed.task.base().task_id,
task_id,
"stdio as_proxy must keep the same id it created: {observed:?}"
);
let missing = FinalTaskId::parse("missing-stdio-gateway-created-task")
.expect("the planted-missing task id is a valid official TaskId");
let missing_get = runtime_block_on_bounded(
&cx,
client.get_task(&cx, RequestId::Number(5), missing, 1 << 20),
)
.expect_err("changing only the task id must not invent a Task on the stdio as_proxy gateway");
let _ = missing_get;
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_creates_official_task_with_progress_marker() {
let cx = Cx::for_request();
let gateway = spawn_modern_http_stdio_as_proxy_gateway_without_precreated_task();
let prefixed = "ext/durable_task";
let marker = modern::ProgressMarker::from("stdio-as-proxy-task-create-progress");
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-http-as-proxy-stdio-tasks-create-progress", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy Tasks gateway");
let unprefixed = runtime_block_on_bounded(
&cx,
client.call_tool_outcome_with_progress_marker(
&cx,
RequestId::Number(2),
"durable_task",
json!({}),
marker.clone(),
1 << 20,
),
)
.expect_err(
"changing only the tool name must not create a Task on the unprefixed stdio path even with a progress token",
);
let _ = unprefixed;
let created = runtime_block_on_bounded(
&cx,
client.call_tool_outcome_with_progress_marker(
&cx,
RequestId::Number(3),
prefixed,
json!({}),
marker,
1 << 20,
),
)
.expect(
"a progress token must not prevent the stdio as_proxy gateway from creating one official Task through ext/durable_task",
);
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the prefixed stdio durable_task must return the official Task result branch after a progress token: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
let observed = runtime_block_on_bounded(
&cx,
client.get_task(&cx, RequestId::Number(4), task_id.clone(), 1 << 20),
)
.expect("typed tasks/get must return the stdio-gateway-created Task");
assert_eq!(
observed.task.base().task_id,
task_id,
"stdio as_proxy must keep the same id it created under a progress token: {observed:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_forwards_inbound_client_implementation() {
let cx = Cx::for_request();
let gateway = spawn_modern_http_stdio_as_proxy_gateway_without_precreated_task();
let mut identified = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-identity", "1.0.0")
.title("Caller Title")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the identified public facade connects to the live stdio as_proxy gateway");
let mut bare = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-identity-bare", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the bare public facade connects to the live stdio as_proxy gateway");
let listed = runtime_block_on_bounded(&cx, identified.list_tools(&cx, None))
.expect("the live stdio as_proxy gateway must advertise the prefixed identity tool");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == "ext/client_identity"),
"as_proxy must prefix the shipped echo identity tool: {listed:?}"
);
let missing =
runtime_block_on_bounded(&cx, identified.call_tool(&cx, "client_identity", json!({})))
.expect_err(
"changing only the tool name must not reach the unprefixed stdio identity handler",
);
let _ = missing;
let identified_result = runtime_block_on_bounded(
&cx,
identified.call_tool(&cx, "ext/client_identity", json!({})),
)
.expect("as_proxy must forward ext/client_identity to the shipped echo handler");
assert!(
identified_result
.content
.iter()
.any(|content| match content {
ContentBlock::Text { text, .. } => {
text == "name=e2e-as-proxy-stdio-identity|title=Caller Title"
}
_ => false,
}),
"as_proxy must stamp the inbound ClientBuilder title onto the upstream handler: {identified_result:?}"
);
let bare_result =
runtime_block_on_bounded(&cx, bare.call_tool(&cx, "ext/client_identity", json!({})))
.expect("as_proxy must still forward ext/client_identity for a bare inbound client");
assert!(
bare_result.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text == "name=e2e-as-proxy-stdio-identity-bare|title=none"
}
_ => false,
}),
"changing only the missing inbound title must keep the upstream handler extras bare: {bare_result:?}"
);
drop(identified);
drop(bare);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_adopts_upstream_implementation() {
let cx = Cx::for_request();
let gateway = spawn_modern_http_stdio_as_proxy_gateway_without_precreated_task();
let client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-server-identity", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy gateway");
let discovered = client.server_discovery();
let implementation = discovered.implementation().expect(
"modern stdio as_proxy must adopt the echo Implementation extras onto gateway discovery",
);
assert_eq!(implementation.title.as_deref(), Some("FastMCP Echo"));
assert_eq!(
implementation.description.as_deref(),
Some("A simple echo server for testing FastMCP.")
);
assert_eq!(
implementation.website_url.as_ref().map(|uri| uri.as_str()),
Some("https://example.test/fastmcp")
);
assert_eq!(
implementation.icons.first().map(|icon| icon.src.as_str()),
Some("https://example.test/echo-icon.png"),
"as_proxy must retain the upstream icon: {implementation:?}"
);
assert_eq!(
discovered
.server_info()
.map(|info| (info.name.as_str(), info.version.as_str())),
Some(("e2e-http-stdio-as-proxy", "1.0.0")),
"as_proxy must keep the gateway name/version while adopting extras"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_bare_upstream_omits_implementation() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_configured(
false,
vec![("FASTMCP_NO_IDENTITY".to_owned(), "1".to_owned())],
);
let client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-server-identity-bare", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the bare-identity stdio as_proxy gateway");
let discovered = client.server_discovery();
assert!(
discovered.implementation().is_none(),
"changing only FASTMCP_NO_IDENTITY must keep the as_proxy gateway name/version-only: {:?}",
discovered.implementation()
);
assert_eq!(
discovered
.server_info()
.map(|info| (info.name.as_str(), info.version.as_str())),
Some(("e2e-http-stdio-as-proxy", "1.0.0")),
"the bare as_proxy gateway must still advertise its own name and version"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_adopts_upstream_instructions() {
let cx = Cx::for_request();
let gateway = spawn_modern_http_stdio_as_proxy_gateway_without_precreated_task();
let client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-server-instructions", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy gateway");
assert_eq!(
client
.server_discovery()
.instructions()
.map(fastmcp_rust::ServerInstructions::as_str),
Some(ECHO_SERVER_INSTRUCTIONS),
"modern stdio as_proxy must adopt the echo discover instructions onto the gateway"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_bare_upstream_omits_instructions() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_configured(
false,
vec![("FASTMCP_NO_INSTRUCTIONS".to_owned(), "1".to_owned())],
);
let client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-server-instructions-bare", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the bare-instructions stdio as_proxy gateway");
assert_eq!(
client.server_discovery().instructions(),
None,
"changing only FASTMCP_NO_INSTRUCTIONS must keep the as_proxy gateway bare"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
fn spawn_modern_http_identity_proxy_gateway(upstream: SocketAddr) -> HttpServerFixture {
spawn_modern_http_identity_proxy_gateway_configured(
upstream, None, false, None, None, false, false, false, false, None,
)
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
fn spawn_modern_http_identity_proxy_gateway_configured(
upstream: SocketAddr,
gateway_instructions: Option<String>,
mask_error_details: bool,
request_timeout_secs: Option<u64>,
cache_tools: Option<Vec<String>>,
rate_limit: bool,
sliding_window: bool,
static_token: bool,
strict_input: bool,
list_page_size: Option<usize>,
) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("identity proxy gateway HTTP server reactor initializes"),
)
.build()
.expect("identity proxy gateway HTTP server installs an owned runtime");
let outcome = runtime.block_on(async move {
let cx =
Cx::current().expect("owned gateway runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err(
"identity proxy gateway HTTP server control receiver went away".to_owned(),
);
}
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream, "/mcp")),
None,
None,
"e2e-http-identity-proxy-gateway".to_owned(),
"e2e-http-identity-proxy-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.map_err(|error| format!("identity proxy gateway HTTP plan failed: {error}"))?;
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-live-identity-upstream",
"native-h1:e2e-live-identity-upstream",
1,
plan,
ClientInfo {
name: "e2e-http-identity-proxy".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.map_err(|error| {
format!("live HTTP identity proxy upstream connect failed: {error}")
})?;
let catalog = proxy
.catalog_typed()
.map_err(|error| format!("live HTTP identity proxy catalog failed: {error}"))?;
let mut builder = modern::ServerBuilder::new("e2e-http-identity-gateway", "1.0.0")
.mask_error_details(mask_error_details);
if let Some(instructions) = gateway_instructions {
builder = builder.instructions(instructions);
}
if let Some(request_timeout_secs) = request_timeout_secs {
builder = builder.request_timeout(request_timeout_secs);
}
if let Some(cache_tools) = cache_tools {
builder = builder.middleware(
caching::ResponseCachingMiddleware::new().include_tools(cache_tools),
);
}
if rate_limit {
builder = builder.middleware(
rate_limiting::RateLimitingMiddleware::new(1.0e-300).burst_capacity(1),
);
}
if sliding_window {
builder = builder.middleware(
rate_limiting::SlidingWindowRateLimitingMiddleware::new(1, 60),
);
}
if static_token {
let verifier = StaticTokenVerifier::new([(
"alpha",
AuthContext::with_subject(PUBLIC_HTTP_AUTH_SUBJECT),
)])
.expect("the deterministic as_proxy gateway bearer verifier is valid")
.with_allowed_schemes(["Bearer"])
.expect("the as_proxy gateway bearer scheme allowlist is valid");
builder = builder.auth_provider(TokenAuthProvider::new(verifier));
}
if strict_input {
builder = builder.strict_input_validation(true);
}
if let Some(page_size) = list_page_size {
builder = builder.list_page_size(page_size);
}
let server = builder
.as_proxy_typed("ext", proxy, catalog)
.map_err(|error| format!("as_proxy_typed identity install failed: {error}"))?
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message =
format!("identity as_proxy gateway HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("identity as_proxy gateway HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err(
"identity as_proxy gateway HTTP server startup receiver went away".to_owned(),
);
}
bound.serve(&cx).await.map_err(|error| {
format!("identity as_proxy gateway HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("identity as_proxy gateway HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => {
panic!("identity as_proxy gateway HTTP server failed to start: {error}")
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("identity as_proxy gateway HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_forwards_inbound_client_implementation() {
let cx = Cx::for_request();
let upstream = spawn_modern_client_identity_http_server();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let prefixed = format!("ext/{PUBLIC_HTTP_CLIENT_IDENTITY_TOOL_NAME}");
let mut identified = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-identity", "1.0.0")
.title("Caller Title")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the identified public facade connects to the live HTTP as_proxy gateway");
let mut bare = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-identity-bare", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the bare public facade connects to the live HTTP as_proxy gateway");
let listed = runtime_block_on_bounded(&cx, identified.list_tools(&cx, None))
.expect("the live HTTP as_proxy gateway must advertise the prefixed identity tool");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed),
"as_proxy_typed must prefix the live identity tool: {listed:?}"
);
let missing = runtime_block_on_bounded(
&cx,
identified.call_tool(&cx, PUBLIC_HTTP_CLIENT_IDENTITY_TOOL_NAME, json!({})),
)
.expect_err("changing only the tool name must not reach the unprefixed identity handler");
let _ = missing;
let identified_result =
runtime_block_on_bounded(&cx, identified.call_tool(&cx, &prefixed, json!({})))
.expect("as_proxy must forward the prefixed identity tool to the live HTTP upstream");
assert!(
identified_result
.content
.iter()
.any(|content| match content {
ContentBlock::Text { text, .. } => {
text == "name=e2e-as-proxy-http-identity|title=Caller Title"
}
_ => false,
}),
"as_proxy must stamp the inbound ClientBuilder title onto the upstream handler: {identified_result:?}"
);
let bare_result = runtime_block_on_bounded(&cx, bare.call_tool(&cx, &prefixed, json!({})))
.expect("as_proxy must still forward the prefixed identity tool for a bare inbound client");
assert!(
bare_result.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text == "name=e2e-as-proxy-http-identity-bare|title=none"
}
_ => false,
}),
"changing only the missing inbound title must keep the upstream handler extras bare: {bare_result:?}"
);
drop(identified);
drop(bare);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_adopts_upstream_implementation() {
let cx = Cx::for_request();
let identified_upstream = spawn_modern_identity_http_server(true);
let gateway = spawn_modern_http_identity_proxy_gateway(identified_upstream.address());
let client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-server-identity", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the identified HTTP as_proxy gateway");
let discovered = client.server_discovery();
let implementation = discovered.implementation().expect(
"modern HTTP as_proxy must adopt the upstream Implementation extras onto gateway discovery",
);
assert_eq!(implementation.title.as_deref(), Some("Identity Title"));
assert_eq!(
implementation.description.as_deref(),
Some("Identity description")
);
assert_eq!(
implementation.website_url.as_ref().map(|uri| uri.as_str()),
Some("https://example.test/fastmcp")
);
assert_eq!(
implementation.icons.first().map(|icon| icon.src.as_str()),
Some("https://example.test/e2e-icon.png"),
"as_proxy must retain the upstream icon: {implementation:?}"
);
assert_eq!(
discovered
.server_info()
.map(|info| (info.name.as_str(), info.version.as_str())),
Some(("e2e-http-identity-gateway", "1.0.0")),
"as_proxy must keep the gateway name/version while adopting extras"
);
drop(client);
gateway.shutdown();
identified_upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_bare_upstream_omits_implementation() {
let cx = Cx::for_request();
let bare_upstream = spawn_modern_identity_http_server(false);
let gateway = spawn_modern_http_identity_proxy_gateway(bare_upstream.address());
let client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-server-identity-bare", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the bare HTTP as_proxy gateway");
let discovered = client.server_discovery();
assert!(
discovered.implementation().is_none(),
"changing only the missing upstream extras must keep the as_proxy gateway name/version-only: {:?}",
discovered.implementation()
);
assert_eq!(
discovered
.server_info()
.map(|info| (info.name.as_str(), info.version.as_str())),
Some(("e2e-http-identity-gateway", "1.0.0")),
"the bare as_proxy gateway must still advertise its own name and version"
);
drop(client);
gateway.shutdown();
bare_upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_adopts_upstream_instructions() {
let cx = Cx::for_request();
let instructed_upstream = spawn_modern_instructions_http_server(true);
let instructed_gateway =
spawn_modern_http_identity_proxy_gateway(instructed_upstream.address());
let override_gateway = spawn_modern_http_identity_proxy_gateway_configured(
instructed_upstream.address(),
Some("gateway-override-instructions".to_owned()),
false,
None,
None,
false,
false,
false,
false,
None,
);
let instructed = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-server-instructions", "1.0.0")
.connect_http_with_cx(
public_http_target(instructed_gateway.address(), "/mcp"),
&cx,
),
)
.expect("the public facade connects to the instructed HTTP as_proxy gateway");
let overridden = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-server-instructions-override", "1.0.0")
.connect_http_with_cx(public_http_target(override_gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the override HTTP as_proxy gateway");
assert_eq!(
instructed
.server_discovery()
.instructions()
.map(fastmcp_rust::ServerInstructions::as_str),
Some(PUBLIC_HTTP_INSTRUCTIONS),
"modern HTTP as_proxy must adopt the upstream discover instructions"
);
assert_eq!(
overridden
.server_discovery()
.instructions()
.map(fastmcp_rust::ServerInstructions::as_str),
Some("gateway-override-instructions"),
"a builder instructions() set before as_proxy_typed must keep the gateway string"
);
drop(instructed);
drop(overridden);
instructed_gateway.shutdown();
override_gateway.shutdown();
instructed_upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_bare_upstream_omits_instructions() {
let cx = Cx::for_request();
let bare_upstream = spawn_modern_instructions_http_server(false);
let gateway = spawn_modern_http_identity_proxy_gateway(bare_upstream.address());
let client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-server-instructions-bare", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the bare HTTP as_proxy gateway");
assert_eq!(
client.server_discovery().instructions(),
None,
"changing only the missing upstream instructions must keep the as_proxy gateway bare"
);
drop(client);
gateway.shutdown();
bare_upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_forwards_hide_as_request_local_on_stateless_http() {
let cx = Cx::for_request();
let upstream = spawn_modern_hide_http_server();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-hide", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy hide gateway");
let prefixed_hide = format!("ext/{PUBLIC_HTTP_HIDE_TOOL_NAME}");
let prefixed_touch = format!("ext/{PUBLIC_HTTP_TOUCH_TOOL_NAME}");
let prefixed_fast = format!("ext/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("the live HTTP as_proxy gateway must advertise the prefixed hide catalog");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed_hide)
&& listed.tools.iter().any(|tool| tool.name == prefixed_touch)
&& listed.tools.iter().any(|tool| tool.name == prefixed_fast),
"as_proxy_typed must prefix the live hide/touch/fast tools: {listed:?}"
);
let before = runtime_block_on_bounded(&cx, client.call_tool(&cx, &prefixed_touch, json!({})))
.expect("as_proxy must forward the prefixed touch tool before hide");
assert!(
!before.is_error,
"changing only the missing hide must keep prefixed touch admitted: {before:?}"
);
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_HIDE_TOOL_NAME, json!({})),
)
.expect_err("changing only the tool name must not reach the unprefixed hide handler");
let _ = missing;
let hidden = runtime_block_on_bounded(&cx, client.call_tool(&cx, &prefixed_hide, json!({})))
.expect("as_proxy must forward the prefixed hide tool to the live HTTP upstream");
assert!(
hidden.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "hidden",
_ => false,
}),
"as_proxy must retain the same-POST upstream disable_tool mutation: {hidden:?}"
);
let still_listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("the live HTTP as_proxy gateway must keep its install-time catalog after hide");
assert!(
still_listed
.tools
.iter()
.any(|tool| tool.name == prefixed_touch),
"as_proxy must not drop the prefixed touch tool from the gateway snapshot: {still_listed:?}"
);
let later = runtime_block_on_bounded(&cx, client.call_tool(&cx, &prefixed_touch, json!({})))
.expect("a later modern HTTP POST of prefixed touch must still complete");
assert!(
!later.is_error,
"modern HTTP as_proxy hide is request-local: a later POST must still admit prefixed touch: {later:?}"
);
assert!(
later.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "silent",
_ => false,
}),
"a later POST of prefixed touch must still run the upstream handler (silent, no listener): {later:?}"
);
let peer = runtime_block_on_bounded(&cx, client.call_tool(&cx, &prefixed_fast, json!({})))
.expect("changing only the tool name must still reach an undisabled HTTP handler");
assert!(
peer.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"as_proxy must keep undisabled HTTP tools callable after hide: {peer:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_forwards_hide_and_show_of_prefixed_tool() {
let cx = Cx::for_request();
let upstream = spawn_legacy_hide_http_server();
let gateway = spawn_legacy_as_proxy_instructions_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-hide",
);
let prefixed_hide = format!("child/{PUBLIC_HTTP_HIDE_TOOL_NAME}");
let prefixed_show = format!("child/{PUBLIC_HTTP_SHOW_TOOL_NAME}");
let prefixed_touch = format!("child/{PUBLIC_HTTP_TOUCH_TOOL_NAME}");
let prefixed_fast = format!("child/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy hide tools/list",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("the live exact-2024 HTTP as_proxy gateway must advertise the prefixed hide catalog");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed_hide)
&& listed.tools.iter().any(|tool| tool.name == prefixed_touch)
&& listed.tools.iter().any(|tool| tool.name == prefixed_fast),
"legacy as_proxy_typed must prefix the live hide/touch/fast tools: {listed:?}"
);
let before = legacy_http_call(
&cx,
&mut client,
&prefixed_touch,
json!({}),
"exact-2024 as_proxy hide touch before",
)
.expect("as_proxy must forward the prefixed touch tool before hide");
assert_eq!(
legacy_http_tool_text(&before).as_deref(),
Some("silent"),
"changing only the missing hide must keep prefixed touch admitted: {before:?}"
);
let missing = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_HIDE_TOOL_NAME,
json!({}),
"exact-2024 as_proxy hide unprefixed",
)
.expect_err("changing only the tool name must not reach the unprefixed hide handler");
let _ = missing;
let hidden = legacy_http_call(
&cx,
&mut client,
&prefixed_hide,
json!({}),
"exact-2024 as_proxy hide tools/call",
)
.expect("as_proxy must forward the prefixed hide tool to the live exact-2024 HTTP upstream");
assert_eq!(
legacy_http_tool_text(&hidden).as_deref(),
Some("hidden"),
"as_proxy must retain the upstream disable_tool mutation: {hidden:?}"
);
let still_listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy hide list after",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect(
"the live exact-2024 HTTP as_proxy gateway must keep its install-time catalog after hide",
);
assert!(
still_listed
.tools
.iter()
.any(|tool| tool.name == prefixed_touch),
"as_proxy must not drop the prefixed touch tool from the gateway snapshot: {still_listed:?}"
);
let disabled = legacy_disabled_tool_observable(legacy_http_call(
&cx,
&mut client,
&prefixed_touch,
json!({}),
"exact-2024 as_proxy hide touch after",
));
assert!(
disabled.contains("disabled")
|| disabled.contains(PUBLIC_HTTP_TOUCH_TOOL_NAME)
|| disabled.contains("Method not found")
|| disabled.contains("MethodNotFound"),
"hide on the live exact-2024 HTTP session must refuse a later prefixed touch: {disabled}"
);
let peer = legacy_http_call(
&cx,
&mut client,
&prefixed_fast,
json!({}),
"exact-2024 as_proxy hide peer",
)
.expect("changing only the tool name must still reach an undisabled HTTP handler");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("fast"),
"as_proxy must keep undisabled HTTP tools callable after hide: {peer:?}"
);
let shown = legacy_http_call(
&cx,
&mut client,
&prefixed_show,
json!({}),
"exact-2024 as_proxy show tools/call",
)
.expect("as_proxy must forward the prefixed show tool to the live exact-2024 HTTP upstream");
assert_eq!(
legacy_http_tool_text(&shown).as_deref(),
Some("shown"),
"as_proxy must retain the upstream enable_tool mutation: {shown:?}"
);
let restored = legacy_http_call(
&cx,
&mut client,
&prefixed_touch,
json!({}),
"exact-2024 as_proxy hide touch restored",
)
.expect("show on the live exact-2024 HTTP session must restore the prefixed touch tool");
assert_eq!(
legacy_http_tool_text(&restored).as_deref(),
Some("silent"),
"as_proxy must keep the restored HTTP touch tool callable: {restored:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_forwards_hide_catalog_of_prefixed_resource_and_prompt() {
let cx = Cx::for_request();
let upstream = spawn_legacy_hide_catalog_http_server();
let gateway = spawn_legacy_as_proxy_instructions_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-hide-catalog",
);
let prefixed_hide = format!("child/{PUBLIC_HTTP_HIDE_CATALOG_TOOL_NAME}");
let prefixed_show = format!("child/{PUBLIC_HTTP_SHOW_CATALOG_TOOL_NAME}");
let prefixed_fast = format!("child/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let prefixed_resource = format!("child/{PUBLIC_HTTP_WATCH_RESOURCE_URI}");
let prefixed_prompt = format!("child/{PUBLIC_HTTP_CATALOG_PROMPT_NAME}");
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy hide-catalog tools/list",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("the live exact-2024 HTTP as_proxy gateway must advertise the hide-catalog tools");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed_hide)
&& listed.tools.iter().any(|tool| tool.name == prefixed_show)
&& listed.tools.iter().any(|tool| tool.name == prefixed_fast),
"legacy as_proxy_typed must prefix the hide-catalog tools: {listed:?}"
);
let before_resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy hide-catalog read before",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: prefixed_resource.clone(),
meta: None,
},
),
)
.expect("as_proxy must read the prefixed watch resource before hide_catalog");
let before_resource_text = serde_json::to_value(&before_resource)
.ok()
.and_then(|value| value["contents"][0]["text"].as_str().map(str::to_owned));
assert_eq!(
before_resource_text.as_deref(),
Some("watched"),
"changing only the missing hide_catalog must keep the prefixed resource readable: {before_resource:?}"
);
let before_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy hide-catalog prompt before",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: prefixed_prompt.clone(),
arguments: None,
meta: None,
},
),
)
.expect("as_proxy must get the prefixed catalog prompt before hide_catalog");
let before_prompt =
serde_json::to_value(before_prompt).expect("the prefixed catalog prompt serializes");
assert_eq!(
before_prompt["messages"][0]["content"]["text"],
json!("catalog"),
"changing only the missing hide_catalog must keep the prefixed prompt admitted: {before_prompt:?}"
);
let missing = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_HIDE_CATALOG_TOOL_NAME,
json!({}),
"exact-2024 as_proxy hide-catalog unprefixed",
)
.expect_err("changing only the tool name must not reach the unprefixed hide_catalog handler");
let _ = missing;
let hidden = legacy_http_call(
&cx,
&mut client,
&prefixed_hide,
json!({}),
"exact-2024 as_proxy hide_catalog tools/call",
)
.expect("as_proxy must forward hide_catalog to the live exact-2024 HTTP upstream");
assert_eq!(
legacy_http_tool_text(&hidden).as_deref(),
Some("hidden"),
"as_proxy must retain the upstream disable_resource and disable_prompt mutations: {hidden:?}"
);
let still_resources = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy hide-catalog resources/list after",
client.list_resources(&cx, legacy_2024::ListResourcesParams::default()),
)
.expect("the gateway snapshot must keep the prefixed resource after hide_catalog");
assert!(
still_resources
.resources
.iter()
.any(|resource| resource.uri == prefixed_resource),
"as_proxy must not drop the prefixed resource from the gateway snapshot: {still_resources:?}"
);
let still_prompts = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy hide-catalog prompts/list after",
client.list_prompts(&cx, legacy_2024::ListPromptsParams::default()),
)
.expect("the gateway snapshot must keep the prefixed prompt after hide_catalog");
assert!(
still_prompts
.prompts
.iter()
.any(|prompt| prompt.name == prefixed_prompt),
"as_proxy must not drop the prefixed prompt from the gateway snapshot: {still_prompts:?}"
);
let disabled_resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy hide-catalog read after",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: prefixed_resource.clone(),
meta: None,
},
),
);
let disabled_resource = match disabled_resource {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
disabled_resource.contains("disabled")
|| disabled_resource.contains(PUBLIC_HTTP_WATCH_RESOURCE_URI)
|| disabled_resource.contains("Method not found")
|| disabled_resource.contains("MethodNotFound"),
"hide_catalog on the live exact-2024 HTTP session must refuse a later prefixed resource read: {disabled_resource}"
);
let disabled_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy hide-catalog prompt after",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: prefixed_prompt.clone(),
arguments: None,
meta: None,
},
),
);
let disabled_prompt = match disabled_prompt {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
disabled_prompt.contains("disabled")
|| disabled_prompt.contains(PUBLIC_HTTP_CATALOG_PROMPT_NAME)
|| disabled_prompt.contains("Method not found")
|| disabled_prompt.contains("MethodNotFound"),
"hide_catalog on the live exact-2024 HTTP session must refuse a later prefixed prompt get: {disabled_prompt}"
);
let peer = legacy_http_call(
&cx,
&mut client,
&prefixed_fast,
json!({}),
"exact-2024 as_proxy hide-catalog peer",
)
.expect("changing only the missing hide_catalog must keep an undisabled HTTP tool callable");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("fast"),
"as_proxy must keep undisabled HTTP tools callable after hide_catalog: {peer:?}"
);
let shown = legacy_http_call(
&cx,
&mut client,
&prefixed_show,
json!({}),
"exact-2024 as_proxy show_catalog tools/call",
)
.expect("as_proxy must forward show_catalog to the live exact-2024 HTTP upstream");
assert_eq!(
legacy_http_tool_text(&shown).as_deref(),
Some("shown"),
"as_proxy must retain the upstream enable_resource and enable_prompt mutations: {shown:?}"
);
let restored_resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy hide-catalog read restored",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: prefixed_resource,
meta: None,
},
),
)
.expect("show_catalog must restore the prefixed watch resource");
let restored_resource_text = serde_json::to_value(&restored_resource)
.ok()
.and_then(|value| value["contents"][0]["text"].as_str().map(str::to_owned));
assert_eq!(
restored_resource_text.as_deref(),
Some("watched"),
"as_proxy must keep the restored HTTP resource readable: {restored_resource:?}"
);
let restored_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy hide-catalog prompt restored",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: prefixed_prompt,
arguments: None,
meta: None,
},
),
)
.expect("show_catalog must restore the prefixed catalog prompt");
let restored_prompt =
serde_json::to_value(restored_prompt).expect("the restored catalog prompt serializes");
assert_eq!(
restored_prompt["messages"][0]["content"]["text"],
json!("catalog"),
"as_proxy must keep the restored HTTP prompt admitted: {restored_prompt:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_mask_error_details_hides_upstream_resource_secret() {
let cx = Cx::for_request();
let upstream = spawn_modern_as_proxy_leak_upstream();
let masked_gateway = spawn_modern_http_identity_proxy_gateway_configured(
upstream.address(),
None,
true,
None,
None,
false,
false,
false,
false,
None,
);
let unmasked_gateway = spawn_modern_http_identity_proxy_gateway_configured(
upstream.address(),
None,
false,
None,
None,
false,
false,
false,
false,
None,
);
let mut masked = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-mask", "1.0.0")
.connect_http_with_cx(public_http_target(masked_gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the masked HTTP as_proxy leak gateway");
let mut unmasked = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-unmask", "1.0.0")
.connect_http_with_cx(public_http_target(unmasked_gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the unmasked HTTP as_proxy leak gateway");
let missing = runtime_block_on_bounded(
&cx,
masked.read_resource(&cx, &format!("ext/{PUBLIC_HTTP_LEAK_RESOURCE_URI}")),
)
.expect_err("modern as_proxy must keep the leak resource URI unprefixed");
let _ = missing;
let masked_err = runtime_block_on_bounded(
&cx,
masked.read_resource(&cx, PUBLIC_HTTP_LEAK_RESOURCE_URI),
)
.expect_err("a leaking upstream resource must stay a resources/read error through as_proxy");
let masked_err = format!("{masked_err:?}");
assert!(
masked_err.contains("Internal server error"),
"gateway mask_error_details must replace the upstream execution secret: {masked_err}"
);
assert!(
!masked_err.contains(PUBLIC_HTTP_LEAK_SECRET),
"gateway mask_error_details must not leak the upstream execution secret: {masked_err}"
);
let unmasked_err = runtime_block_on_bounded(
&cx,
unmasked.read_resource(&cx, PUBLIC_HTTP_LEAK_RESOURCE_URI),
)
.expect_err("changing only the gateway mask must still refuse the leaking resource");
let unmasked_err = format!("{unmasked_err:?}");
assert!(
unmasked_err.contains(PUBLIC_HTTP_LEAK_SECRET),
"disabling only the gateway mask must keep the upstream execution secret: {unmasked_err}"
);
let peer = runtime_block_on_bounded(
&cx,
masked.call_tool(&cx, &format!("ext/{PUBLIC_HTTP_FAST_TOOL_NAME}"), json!({})),
)
.expect("changing only the leaking resource must keep an undisabled as_proxy tool callable");
assert!(
peer.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"as_proxy must keep undisabled HTTP tools callable after a masked leak: {peer:?}"
);
drop(masked);
drop(unmasked);
masked_gateway.shutdown();
unmasked_gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_mask_error_details_hides_upstream_resource_secret() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_leak_upstream();
let masked_gateway =
spawn_legacy_as_proxy_instructions_gateway_named(upstream.address(), None, true, None);
let unmasked_gateway =
spawn_legacy_as_proxy_instructions_gateway_named(upstream.address(), None, false, None);
let mut masked = connect_legacy_http_client(
&cx,
masked_gateway.address(),
"e2e-public-http-legacy-as-proxy-mask",
);
let mut unmasked = connect_legacy_http_client(
&cx,
unmasked_gateway.address(),
"e2e-public-http-legacy-as-proxy-unmask",
);
let prefixed = format!("child/{PUBLIC_HTTP_LEAK_RESOURCE_URI}");
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy mask unprefixed leak",
masked.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_LEAK_RESOURCE_URI.to_owned(),
meta: None,
},
),
)
.expect_err("changing only the missing prefix must not reach the unprefixed leak resource");
let _ = missing;
let masked_err = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy mask leak read",
masked.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: prefixed.clone(),
meta: None,
},
),
);
let masked_err = match masked_err {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
masked_err.contains("Internal server error"),
"gateway mask_error_details must replace the upstream execution secret: {masked_err}"
);
assert!(
!masked_err.contains(PUBLIC_HTTP_LEAK_SECRET),
"gateway mask_error_details must not leak the upstream execution secret: {masked_err}"
);
let unmasked_err = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy unmask leak read",
unmasked.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: prefixed,
meta: None,
},
),
);
let unmasked_err = match unmasked_err {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
unmasked_err.contains(PUBLIC_HTTP_LEAK_SECRET),
"disabling only the gateway mask must keep the upstream execution secret: {unmasked_err}"
);
drop(masked);
drop(unmasked);
masked_gateway.shutdown();
unmasked_gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_mask_error_details_hides_upstream_resource_secret() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_with_options(
false,
Vec::new(),
true,
None,
None,
false,
false,
false,
false,
None,
);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-mask", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the masked stdio as_proxy leak gateway");
let missing = runtime_block_on_bounded(&cx, client.read_resource(&cx, "ext/info://leak"))
.expect_err("modern stdio as_proxy must keep the leak resource URI unprefixed");
let _ = missing;
let masked_err = runtime_block_on_bounded(&cx, client.read_resource(&cx, "info://leak"))
.expect_err(
"a leaking upstream stdio resource must stay a resources/read error through as_proxy",
);
let masked_err = format!("{masked_err:?}");
assert!(
masked_err.contains("Internal server error"),
"gateway mask_error_details must replace the stdio upstream execution secret: {masked_err}"
);
assert!(
!masked_err.contains("secret-db-dsn"),
"gateway mask_error_details must not leak the stdio upstream execution secret: {masked_err}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_unmask_error_details_keeps_upstream_resource_secret() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_with_options(
false,
Vec::new(),
false,
None,
None,
false,
false,
false,
false,
None,
);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-unmask", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the unmasked stdio as_proxy leak gateway");
let unmasked_err = runtime_block_on_bounded(&cx, client.read_resource(&cx, "info://leak"))
.expect_err("changing only the gateway mask must still refuse the leaking stdio resource");
let unmasked_err = format!("{unmasked_err:?}");
assert!(
unmasked_err.contains("secret-db-dsn"),
"disabling only the gateway mask must keep the stdio upstream execution secret: {unmasked_err}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_mask_error_details_hides_upstream_resource_secret() {
let cx = Cx::for_request();
let masked_gateway = spawn_legacy_http_stdio_as_proxy_gateway_with_options(
Vec::new(),
true,
None,
None,
false,
false,
false,
false,
None,
);
let unmasked_gateway = spawn_legacy_http_stdio_as_proxy_gateway_with_options(
Vec::new(),
false,
None,
None,
false,
false,
false,
false,
None,
);
let mut masked = connect_legacy_http_client(
&cx,
masked_gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-mask",
);
let mut unmasked = connect_legacy_http_client(
&cx,
unmasked_gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-unmask",
);
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy mask unprefixed leak",
masked.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "info://leak".to_owned(),
meta: None,
},
),
)
.expect_err("changing only the missing prefix must not reach the unprefixed leak resource");
let _ = missing;
let masked_err = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy mask leak read",
masked.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "ext/info://leak".to_owned(),
meta: None,
},
),
);
let masked_err = match masked_err {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
masked_err.contains("Internal server error"),
"gateway mask_error_details must replace the stdio upstream execution secret: {masked_err}"
);
assert!(
!masked_err.contains("secret-db-dsn"),
"gateway mask_error_details must not leak the stdio upstream execution secret: {masked_err}"
);
let unmasked_err = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy unmask leak read",
unmasked.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "ext/info://leak".to_owned(),
meta: None,
},
),
);
let unmasked_err = match unmasked_err {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
unmasked_err.contains("secret-db-dsn"),
"disabling only the gateway mask must keep the stdio upstream execution secret: {unmasked_err}"
);
drop(masked);
drop(unmasked);
masked_gateway.shutdown();
unmasked_gateway.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_forwards_handler_timeout_of_prefixed_slow_tool() {
let cx = Cx::for_request();
let upstream = spawn_modern_handler_timeout_http_server();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-timeout", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy timeout gateway");
let prefixed_slow = format!("ext/{PUBLIC_HTTP_SLOW_TOOL_NAME}");
let prefixed_fast = format!("ext/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_SLOW_TOOL_NAME, json!({})),
)
.expect_err("changing only the tool name must not reach the unprefixed slow handler");
let _ = missing;
let timed_out = runtime_block_on_bounded(&cx, client.call_tool(&cx, &prefixed_slow, json!({})));
let timed_out = match timed_out {
Ok(result) => {
assert!(
result.is_error,
"a handler that outlives its timeout must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
timed_out.contains("Request timeout exceeded")
|| timed_out.contains("RequestCancelled")
|| timed_out.contains("Request cancelled"),
"the refused prefixed slow tools/call must keep the handler-timeout error: {timed_out}"
);
let peer = runtime_block_on_bounded(&cx, client.call_tool(&cx, &prefixed_fast, json!({})))
.expect("changing only the tool name must still reach the undisabled fast handler");
assert!(
peer.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"as_proxy must keep the prefixed fast peer callable after a handler timeout: {peer:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_forwards_handler_timeout_of_prefixed_slow_tool() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_timeout_upstream();
let gateway = spawn_legacy_as_proxy_instructions_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-timeout",
);
let prefixed_slow = format!("child/{PUBLIC_HTTP_SLOW_TOOL_NAME}");
let prefixed_fast = format!("child/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let missing = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_SLOW_TOOL_NAME,
json!({}),
"exact-2024 as_proxy timeout unprefixed",
)
.expect_err("changing only the tool name must not reach the unprefixed slow handler");
let _ = missing;
let timed_out = legacy_http_call(
&cx,
&mut client,
&prefixed_slow,
json!({}),
"exact-2024 as_proxy timeout slow",
);
let timed_out = match timed_out {
Ok(result) => {
assert!(
result.is_error,
"a handler that outlives its timeout must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
timed_out.contains("Request timeout exceeded") || timed_out.contains("RequestCancelled"),
"the refused prefixed slow tools/call must keep the handler-timeout error: {timed_out}"
);
let peer = legacy_http_call(
&cx,
&mut client,
&prefixed_fast,
json!({}),
"exact-2024 as_proxy timeout peer",
)
.expect("changing only the tool name must still reach the undisabled fast handler");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("fast"),
"as_proxy must keep the prefixed fast peer callable after a handler timeout: {peer:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_forwards_handler_timeout_of_prefixed_slow_tool() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_configured(false, Vec::new());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-timeout", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy timeout gateway");
let missing = runtime_block_on_bounded(&cx, client.call_tool(&cx, "slow_echo", json!({})))
.expect_err("changing only the tool name must not reach the unprefixed stdio slow handler");
let _ = missing;
let timed_out =
runtime_block_on_bounded(&cx, client.call_tool(&cx, "ext/slow_echo", json!({})));
let timed_out = match timed_out {
Ok(result) => {
assert!(
result.is_error,
"a handler that outlives its timeout must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
timed_out.contains("Request timeout exceeded")
|| timed_out.contains("RequestCancelled")
|| timed_out.contains("Request cancelled"),
"the refused prefixed stdio slow tools/call must keep the handler-timeout error: {timed_out}"
);
let peer = runtime_block_on_bounded(&cx, client.call_tool(&cx, "ext/fast_echo", json!({})))
.expect("changing only the tool name must still reach the undisabled stdio fast handler");
assert!(
peer.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"as_proxy must keep the prefixed stdio fast peer callable after a handler timeout: {peer:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_forwards_handler_timeout_of_prefixed_slow_tool() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-timeout",
);
let missing = legacy_http_call(
&cx,
&mut client,
"slow_echo",
json!({}),
"exact-2024 stdio as_proxy timeout unprefixed",
)
.expect_err("changing only the tool name must not reach the unprefixed stdio slow handler");
let _ = missing;
let timed_out = legacy_http_call(
&cx,
&mut client,
"ext/slow_echo",
json!({}),
"exact-2024 stdio as_proxy timeout slow",
);
let timed_out = match timed_out {
Ok(result) => {
assert!(
result.is_error,
"a handler that outlives its timeout must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
timed_out.contains("Request timeout exceeded") || timed_out.contains("RequestCancelled"),
"the refused prefixed stdio slow tools/call must keep the handler-timeout error: {timed_out}"
);
let peer = legacy_http_call(
&cx,
&mut client,
"ext/fast_echo",
json!({}),
"exact-2024 stdio as_proxy timeout peer",
)
.expect("changing only the tool name must still reach the undisabled stdio fast handler");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("fast"),
"as_proxy must keep the prefixed stdio fast peer callable after a handler timeout: {peer:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_include_tags_filters_prefixed_tools() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-tags",
);
let demo = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy includeTags demo",
client.list_tools(
&cx,
legacy_2024::ListToolsParams {
include_tags: Some(vec!["demo".to_owned()]),
..legacy_2024::ListToolsParams::default()
},
),
)
.expect("exact-2024 as_proxy must admit a tagged tools/list of the prefixed stdio catalog");
assert!(
demo.tools.iter().any(|tool| tool.name == "ext/echo")
&& demo
.tools
.iter()
.any(|tool| tool.tags.iter().any(|tag| tag == "demo")),
"includeTags demo must retain the prefixed echo tool and its demo tag: {demo:?}"
);
assert!(
!demo.tools.iter().any(|tool| tool.name == "ext/add"),
"includeTags demo must omit the math-tagged prefixed add tool: {demo:?}"
);
assert!(
!demo.tools.iter().any(|tool| tool.name == "echo"),
"a nonempty as_proxy prefix must not keep the unprefixed echo tool under includeTags: {demo:?}"
);
let math = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy includeTags math",
client.list_tools(
&cx,
legacy_2024::ListToolsParams {
include_tags: Some(vec!["math".to_owned()]),
..legacy_2024::ListToolsParams::default()
},
),
)
.expect("changing only includeTags must still list the prefixed stdio catalog");
assert!(
math.tools.iter().any(|tool| tool.name == "ext/add"),
"includeTags math must retain the prefixed add tool: {math:?}"
);
assert!(
!math.tools.iter().any(|tool| tool.name == "ext/echo"),
"includeTags math must omit the demo-tagged prefixed echo tool: {math:?}"
);
let excluded = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy excludeTags demo",
client.list_tools(
&cx,
legacy_2024::ListToolsParams {
exclude_tags: Some(vec!["demo".to_owned()]),
..legacy_2024::ListToolsParams::default()
},
),
)
.expect("changing only excludeTags must still list the prefixed stdio catalog");
assert!(
!excluded.tools.iter().any(|tool| tool.name == "ext/echo"),
"excludeTags demo must omit the prefixed echo tool: {excluded:?}"
);
assert!(
excluded.tools.iter().any(|tool| tool.name == "ext/add"),
"excludeTags demo must keep the prefixed add tool: {excluded:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_include_tags_filters_prefixed_tools() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_timeout_upstream();
let gateway = spawn_legacy_as_proxy_instructions_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-tags",
);
let prefixed_cursor = format!("child/{PUBLIC_HTTP_TOOL_NAME}");
let prefixed_fast = format!("child/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let cursor = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy includeTags cursor",
client.list_tools(
&cx,
legacy_2024::ListToolsParams {
include_tags: Some(vec!["cursor".to_owned()]),
..legacy_2024::ListToolsParams::default()
},
),
)
.expect("exact-2024 as_proxy must admit a tagged tools/list of the prefixed HTTP catalog");
assert!(
cursor.tools.iter().any(|tool| tool.name == prefixed_cursor)
&& cursor
.tools
.iter()
.any(|tool| tool.tags.iter().any(|tag| tag == "cursor")),
"includeTags cursor must retain the prefixed value tool and its cursor tag: {cursor:?}"
);
assert!(
!cursor.tools.iter().any(|tool| tool.name == prefixed_fast),
"includeTags cursor must omit the untagged prefixed fast tool: {cursor:?}"
);
assert!(
!cursor
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME),
"a nonempty as_proxy prefix must not keep the unprefixed value tool under includeTags: {cursor:?}"
);
let excluded = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy excludeTags cursor",
client.list_tools(
&cx,
legacy_2024::ListToolsParams {
exclude_tags: Some(vec!["cursor".to_owned()]),
..legacy_2024::ListToolsParams::default()
},
),
)
.expect("changing only excludeTags must still list the prefixed HTTP catalog");
assert!(
!excluded
.tools
.iter()
.any(|tool| tool.name == prefixed_cursor),
"excludeTags cursor must omit the prefixed value tool: {excluded:?}"
);
assert!(
excluded.tools.iter().any(|tool| tool.name == prefixed_fast),
"excludeTags cursor must keep the untagged prefixed fast tool: {excluded:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_configured(false, Vec::new());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-compose", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy compose gateway");
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "compose_echo", json!({ "message": "alpha" })),
)
.expect_err("changing only the tool name must not reach the unprefixed compose handler");
let _ = missing;
let composed = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/compose_echo", json!({ "message": "alpha" })),
)
.expect("as_proxy must forward prefixed compose_echo onto the live stdio echo process");
assert!(
composed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text.starts_with("compose:alpha|") && text.contains("echo-server")
}
_ => false,
}),
"as_proxy must retain the nested echo text and server-info resource: {composed:?}"
);
let missing_tool = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
"ext/compose_echo",
json!({
"message": "alpha",
"tool": "stdio-e2e-missing",
}),
),
);
let missing_tool = match missing_tool {
Ok(result) => {
assert!(
result.is_error,
"changing only the nested tool name must stay a handler-visible refusal: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("stdio-e2e-missing") || missing_tool.contains("compose-nested-tool"),
"as_proxy compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-compose",
);
let missing = legacy_http_call(
&cx,
&mut client,
"compose_echo",
json!({ "message": "alpha" }),
"exact-2024 stdio as_proxy compose unprefixed",
)
.expect_err("changing only the tool name must not reach the unprefixed compose handler");
let _ = missing;
let composed = legacy_http_call(
&cx,
&mut client,
"ext/compose_echo",
json!({ "message": "alpha" }),
"exact-2024 stdio as_proxy compose",
)
.expect("as_proxy must forward prefixed compose_echo onto the live LegacyOnly echo process");
let composed_text = legacy_http_tool_text(&composed);
assert!(
composed_text
.as_deref()
.is_some_and(|text| text.starts_with("compose:alpha|") && text.contains("echo-server")),
"as_proxy must retain the nested echo text and server-info resource: {composed:?}"
);
let missing_tool = legacy_http_call(
&cx,
&mut client,
"ext/compose_echo",
json!({
"message": "alpha",
"tool": "stdio-e2e-missing",
}),
"exact-2024 stdio as_proxy compose missing nested tool",
);
let missing_tool = match missing_tool {
Ok(result) => {
assert!(
result.is_error,
"changing only the nested tool name must stay a handler-visible refusal: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("stdio-e2e-missing") || missing_tool.contains("compose-nested-tool"),
"as_proxy compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_composes_nested_prompt() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_configured(false, Vec::new());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-compose-prompt", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy compose-prompt gateway");
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "compose_prompt", json!({ "name": "alpha" })),
)
.expect_err("changing only the tool name must not reach the unprefixed compose_prompt handler");
let _ = missing;
let composed = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/compose_prompt", json!({ "name": "alpha" })),
)
.expect("as_proxy must forward prefixed compose_prompt onto the live stdio echo process");
assert!(
composed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text == "compose-prompt:Please greet alpha in a friendly way."
}
_ => false,
}),
"as_proxy must retain the nested greeting prompt text: {composed:?}"
);
let missing_prompt = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
"ext/compose_prompt",
json!({
"name": "alpha",
"prompt": "stdio-e2e-missing",
}),
),
);
let missing_prompt = match missing_prompt {
Ok(result) => {
assert!(
result.is_error,
"changing only the nested prompt name must stay a handler-visible refusal: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("stdio-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy compose_prompt must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_composes_nested_prompt() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-compose-prompt",
);
let missing = legacy_http_call(
&cx,
&mut client,
"compose_prompt",
json!({ "name": "alpha" }),
"exact-2024 stdio as_proxy compose_prompt unprefixed",
)
.expect_err("changing only the tool name must not reach the unprefixed compose_prompt handler");
let _ = missing;
let composed = legacy_http_call(
&cx,
&mut client,
"ext/compose_prompt",
json!({ "name": "alpha" }),
"exact-2024 stdio as_proxy compose_prompt",
)
.expect("as_proxy must forward prefixed compose_prompt onto the live LegacyOnly echo process");
assert_eq!(
legacy_http_tool_text(&composed).as_deref(),
Some("compose-prompt:Please greet alpha in a friendly way."),
"as_proxy must retain the nested greeting prompt text: {composed:?}"
);
let missing_prompt = legacy_http_call(
&cx,
&mut client,
"ext/compose_prompt",
json!({
"name": "alpha",
"prompt": "stdio-e2e-missing",
}),
"exact-2024 stdio as_proxy compose_prompt missing nested prompt",
);
let missing_prompt = match missing_prompt {
Ok(result) => {
assert!(
result.is_error,
"changing only the nested prompt name must stay a handler-visible refusal: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("stdio-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy compose_prompt must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_resource_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_configured(false, Vec::new());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-resource-compose", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy resource-compose gateway");
let missing = runtime_block_on_bounded(&cx, client.read_resource(&cx, "ext/info://compose"))
.expect_err("modern as_proxy must keep the compose resource URI unprefixed");
let _ = missing;
let composed = runtime_block_on_bounded(&cx, client.read_resource(&cx, "info://compose"))
.expect("as_proxy must forward unprefixed info://compose onto the live stdio echo process");
assert!(
composed.contents.iter().any(|content| match content {
EmbeddedResourceContents::Text { text, .. } => {
text.starts_with("compose:alpha|") && text.contains("echo-server")
}
_ => false,
}),
"as_proxy resources/read must retain the nested echo text and server-info resource: {composed:?}"
);
let missing_tool = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, "info://compose-missing-tool"),
);
let missing_tool = match missing_tool {
Ok(result) => panic!(
"changing only the nested tool name must stay a handler-visible refusal: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("stdio-e2e-missing") || missing_tool.contains("compose-nested-tool"),
"as_proxy resource compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_resource_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-resource-compose",
);
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy resource compose unprefixed",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "info://compose".to_owned(),
meta: None,
},
),
)
.expect_err("changing only the missing prefix must not reach the unprefixed compose resource");
let _ = missing;
let composed = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy resource compose",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "ext/info://compose".to_owned(),
meta: None,
},
),
)
.expect("as_proxy must forward prefixed info://compose onto the live LegacyOnly echo process");
let composed =
serde_json::to_value(composed).expect("the exact-2024 stdio resource result serializes");
let text = composed["contents"][0]["text"].as_str().unwrap_or_default();
assert!(
text.starts_with("compose:alpha|") && text.contains("echo-server"),
"as_proxy resources/read must retain the nested echo text and server-info resource: {composed}"
);
let missing_tool = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy resource compose missing nested tool",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "ext/info://compose-missing-tool".to_owned(),
meta: None,
},
),
);
let missing_tool = match missing_tool {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("stdio-e2e-missing") || missing_tool.contains("compose-nested-tool"),
"as_proxy resource compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_prompt_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_configured(false, Vec::new());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-prompt-compose", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy prompt-compose gateway");
let missing = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
"compose_greeting",
HashMap::from([("name".to_owned(), "alpha".to_owned())]),
),
)
.expect_err("changing only the prompt name must not reach the unprefixed compose_greeting");
let _ = missing;
let composed = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
"ext/compose_greeting",
HashMap::from([("name".to_owned(), "alpha".to_owned())]),
),
)
.expect("as_proxy must forward prefixed compose_greeting onto the live stdio echo process");
assert!(
composed
.messages
.iter()
.any(|message| match &message.content {
ContentBlock::Text { text, .. } => {
text.starts_with("compose:alpha|") && text.contains("echo-server")
}
_ => false,
}),
"as_proxy prompts/get must retain the nested echo text and server-info resource: {composed:?}"
);
let missing_tool = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
"ext/compose_greeting",
HashMap::from([
("name".to_owned(), "alpha".to_owned()),
("tool".to_owned(), "stdio-e2e-missing".to_owned()),
]),
),
);
let missing_tool = match missing_tool {
Ok(result) => panic!(
"changing only the nested tool name must stay a handler-visible refusal: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("stdio-e2e-missing") || missing_tool.contains("compose-nested-tool"),
"as_proxy prompt compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_prompt_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-prompt-compose",
);
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy prompt compose unprefixed",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: "compose_greeting".to_owned(),
arguments: Some(HashMap::from([("name".to_owned(), "alpha".to_owned())])),
meta: None,
},
),
)
.expect_err("changing only the missing prefix must not reach the unprefixed compose_greeting");
let _ = missing;
let composed = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy prompt compose",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: "ext/compose_greeting".to_owned(),
arguments: Some(HashMap::from([("name".to_owned(), "alpha".to_owned())])),
meta: None,
},
),
)
.expect(
"as_proxy must forward prefixed compose_greeting onto the live LegacyOnly echo process",
);
let composed =
serde_json::to_value(composed).expect("the exact-2024 stdio prompt result serializes");
let text = composed["messages"][0]["content"]["text"]
.as_str()
.unwrap_or_default();
assert!(
text.starts_with("compose:alpha|") && text.contains("echo-server"),
"as_proxy prompts/get must retain the nested echo text and server-info resource: {composed}"
);
let missing_tool = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy prompt compose missing nested tool",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: "ext/compose_greeting".to_owned(),
arguments: Some(HashMap::from([
("name".to_owned(), "alpha".to_owned()),
("tool".to_owned(), "stdio-e2e-missing".to_owned()),
])),
meta: None,
},
),
);
let missing_tool = match missing_tool {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("stdio-e2e-missing") || missing_tool.contains("compose-nested-tool"),
"as_proxy prompt compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_from_prompt_composes_nested_prompt() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_configured(false, Vec::new());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-from-prompt", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy from-prompt gateway");
let missing = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
"compose_from_prompt",
HashMap::from([("name".to_owned(), "alpha".to_owned())]),
),
)
.expect_err("changing only the prompt name must not reach the unprefixed compose_from_prompt");
let _ = missing;
let composed = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
"ext/compose_from_prompt",
HashMap::from([("name".to_owned(), "alpha".to_owned())]),
),
)
.expect("as_proxy must forward prefixed compose_from_prompt onto the live stdio echo process");
assert!(
composed
.messages
.iter()
.any(|message| match &message.content {
ContentBlock::Text { text, .. } => {
text == "compose-prompt:Please greet alpha in a friendly way."
}
_ => false,
}),
"as_proxy prompts/get must retain the nested greeting prompt text: {composed:?}"
);
let missing_prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
"ext/compose_from_prompt",
HashMap::from([
("name".to_owned(), "alpha".to_owned()),
("prompt".to_owned(), "stdio-e2e-missing".to_owned()),
]),
),
);
let missing_prompt = match missing_prompt {
Ok(result) => panic!(
"changing only the nested prompt name must stay a handler-visible refusal: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("stdio-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy from-prompt compose must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_from_prompt_composes_nested_prompt() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-from-prompt",
);
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy from-prompt unprefixed",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: "compose_from_prompt".to_owned(),
arguments: Some(HashMap::from([("name".to_owned(), "alpha".to_owned())])),
meta: None,
},
),
)
.expect_err(
"changing only the missing prefix must not reach the unprefixed compose_from_prompt",
);
let _ = missing;
let composed = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy from-prompt",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: "ext/compose_from_prompt".to_owned(),
arguments: Some(HashMap::from([("name".to_owned(), "alpha".to_owned())])),
meta: None,
},
),
)
.expect(
"as_proxy must forward prefixed compose_from_prompt onto the live LegacyOnly echo process",
);
let composed =
serde_json::to_value(composed).expect("the exact-2024 stdio prompt result serializes");
assert_eq!(
composed["messages"][0]["content"]["text"],
json!("compose-prompt:Please greet alpha in a friendly way."),
"as_proxy prompts/get must retain the nested greeting prompt text: {composed}"
);
let missing_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy from-prompt missing nested prompt",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: "ext/compose_from_prompt".to_owned(),
arguments: Some(HashMap::from([
("name".to_owned(), "alpha".to_owned()),
("prompt".to_owned(), "stdio-e2e-missing".to_owned()),
])),
meta: None,
},
),
);
let missing_prompt = match missing_prompt {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("stdio-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy from-prompt compose must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_forwards_inbound_completion_progress_marker() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-complete-progress",
);
let params = legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt {
name: "ext/greeting".to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "name".to_owned(),
value: "co".to_owned(),
},
meta: None,
};
runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy completion/complete before token",
client.complete(&cx, params.clone()),
)
.expect("prefixed greeting complete must complete without a progressToken");
let silent = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"omitting only the progressToken must keep stdio as_proxy completion/complete silent: {silent:?}"
);
let mut unprefixed = params.clone();
unprefixed.reference = legacy_2024::LegacyCompletionReference::Prompt {
name: "greeting".to_owned(),
};
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy completion unprefixed",
client.complete_with_progress_marker(
&cx,
unprefixed,
legacy_2024::ProgressMarker::from("stdio-legacy-as-proxy-complete-unprefixed"),
),
);
assert!(
missing.is_err(),
"changing only the prompt name must refuse before the unprefixed stdio completion provider: {missing:?}"
);
let marker = legacy_2024::ProgressMarker::from("stdio-legacy-as-proxy-complete-progress");
let progressed = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy completion/complete with token",
client.complete_with_progress_marker(&cx, params, marker.clone()),
)
.expect("stdio as_proxy must forward an inbound progressToken onto ext/greeting completion");
assert_eq!(
progressed.completion.values,
vec!["stdio-completion-legacy".to_owned()],
"stdio as_proxy must still retain the echo completion value after the silent call: {progressed:?}"
);
let progress =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == marker
&& params.message.as_deref() == Some("stdio-completion-legacy-halfway")
)),
"stdio as_proxy must retain greeting completion notifications/progress: {progress:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_resource_composes_nested_prompt() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_configured(false, Vec::new());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-resource-compose-prompt", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect(
"the public facade connects to the live stdio as_proxy resource-compose-prompt gateway",
);
let missing =
runtime_block_on_bounded(&cx, client.read_resource(&cx, "ext/info://compose-prompt"))
.expect_err("modern as_proxy must keep the compose-prompt resource URI unprefixed");
let _ = missing;
let composed = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, "info://compose-prompt"),
)
.expect(
"as_proxy must forward unprefixed info://compose-prompt onto the live stdio echo process",
);
assert!(
composed.contents.iter().any(|content| match content {
EmbeddedResourceContents::Text { text, .. } => {
text == "compose-prompt:Please greet alpha in a friendly way."
}
_ => false,
}),
"as_proxy resources/read must retain the nested greeting prompt text: {composed:?}"
);
let missing_prompt = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, "info://compose-prompt-missing"),
);
let missing_prompt = match missing_prompt {
Ok(result) => panic!(
"changing only the nested prompt name must stay a handler-visible refusal: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("stdio-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy resource compose-prompt must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_resource_composes_nested_prompt() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-resource-compose-prompt",
);
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy resource compose-prompt unprefixed",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "info://compose-prompt".to_owned(),
meta: None,
},
),
)
.expect_err(
"changing only the missing prefix must not reach the unprefixed compose-prompt resource",
);
let _ = missing;
let composed = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy resource compose-prompt",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "ext/info://compose-prompt".to_owned(),
meta: None,
},
),
)
.expect("as_proxy must forward prefixed info://compose-prompt onto the live LegacyOnly echo process");
let composed =
serde_json::to_value(composed).expect("the exact-2024 stdio resource result serializes");
assert_eq!(
composed["contents"][0]["text"],
json!("compose-prompt:Please greet alpha in a friendly way."),
"as_proxy resources/read must retain the nested greeting prompt text: {composed}"
);
let missing_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy resource compose-prompt missing nested prompt",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "ext/info://compose-prompt-missing".to_owned(),
meta: None,
},
),
);
let missing_prompt = match missing_prompt {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("stdio-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy resource compose-prompt must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let upstream = spawn_modern_compose_and_state_http_server();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-compose", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy compose gateway");
let prefixed = format!("ext/{PUBLIC_HTTP_COMPOSE_TOOL_NAME}");
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_COMPOSE_TOOL_NAME,
json!({"value": "alpha"}),
),
)
.expect_err("changing only the tool name must not reach the unprefixed compose handler");
let _ = missing;
let composed = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.expect("as_proxy must forward prefixed compose onto the live HTTP upstream");
assert!(
composed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text == "compose:tool:alpha|resource:deterministic"
}
_ => false,
}),
"as_proxy must retain the nested tool text and resource: {composed:?}"
);
let missing_tool = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
&prefixed,
json!({"value": "alpha", "tool": "public-http-e2e-missing"}),
),
);
let missing_tool = match missing_tool {
Ok(result) => {
assert!(
result.is_error,
"changing only the nested tool name must stay a handler-visible refusal: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("compose-nested-tool"),
"as_proxy compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let upstream = spawn_legacy_compose_http_server();
let gateway = spawn_legacy_as_proxy_instructions_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-compose",
);
let prefixed = format!("child/{PUBLIC_HTTP_COMPOSE_TOOL_NAME}");
let missing = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_COMPOSE_TOOL_NAME,
json!({"value": "alpha"}),
"exact-2024 HTTP as_proxy compose unprefixed",
)
.expect_err("changing only the tool name must not reach the unprefixed compose handler");
let _ = missing;
let composed = legacy_http_call(
&cx,
&mut client,
&prefixed,
json!({"value": "alpha"}),
"exact-2024 HTTP as_proxy compose",
)
.expect("as_proxy must forward prefixed compose onto the live exact-2024 HTTP upstream");
assert_eq!(
legacy_http_tool_text(&composed).as_deref(),
Some("compose:tool:alpha|resource:deterministic"),
"as_proxy must retain the nested tool text and resource: {composed:?}"
);
let missing_tool = legacy_http_call(
&cx,
&mut client,
&prefixed,
json!({"value": "alpha", "tool": "public-http-e2e-missing"}),
"exact-2024 HTTP as_proxy compose missing nested tool",
);
let missing_tool = match missing_tool {
Ok(result) => {
assert!(
result.is_error,
"changing only the nested tool name must stay a handler-visible refusal: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("compose-nested-tool"),
"as_proxy compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_composes_nested_prompt() {
let cx = Cx::for_request();
let upstream = spawn_modern_compose_prompt_http_server();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-compose-prompt", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy compose-prompt gateway");
let prefixed = format!("ext/{PUBLIC_HTTP_PROMPT_COMPOSE_TOOL_NAME}");
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_PROMPT_COMPOSE_TOOL_NAME,
json!({"subject": "alpha"}),
),
)
.expect_err("changing only the tool name must not reach the unprefixed compose_prompt handler");
let _ = missing;
let composed = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, &prefixed, json!({"subject": "alpha"})),
)
.expect("as_proxy must forward prefixed compose_prompt onto the live HTTP upstream");
assert!(
composed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "compose-prompt:prompt:alpha",
_ => false,
}),
"as_proxy must retain the nested prompt text: {composed:?}"
);
let missing_prompt = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
&prefixed,
json!({
"subject": "alpha",
"prompt": "public-http-e2e-missing",
}),
),
);
let missing_prompt = match missing_prompt {
Ok(result) => {
assert!(
result.is_error,
"changing only the nested prompt name must stay a handler-visible refusal: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("public-http-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy compose_prompt must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_composes_nested_prompt() {
let cx = Cx::for_request();
let upstream = spawn_legacy_compose_prompt_http_server();
let gateway = spawn_legacy_as_proxy_instructions_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-compose-prompt",
);
let prefixed = format!("child/{PUBLIC_HTTP_PROMPT_COMPOSE_TOOL_NAME}");
let missing = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_PROMPT_COMPOSE_TOOL_NAME,
json!({"subject": "alpha"}),
"exact-2024 HTTP as_proxy compose_prompt unprefixed",
)
.expect_err("changing only the tool name must not reach the unprefixed compose_prompt handler");
let _ = missing;
let composed = legacy_http_call(
&cx,
&mut client,
&prefixed,
json!({"subject": "alpha"}),
"exact-2024 HTTP as_proxy compose_prompt",
)
.expect("as_proxy must forward prefixed compose_prompt onto the live exact-2024 HTTP upstream");
assert_eq!(
legacy_http_tool_text(&composed).as_deref(),
Some("compose-prompt:prompt:alpha"),
"as_proxy must retain the nested prompt text: {composed:?}"
);
let missing_prompt = legacy_http_call(
&cx,
&mut client,
&prefixed,
json!({
"subject": "alpha",
"prompt": "public-http-e2e-missing",
}),
"exact-2024 HTTP as_proxy compose_prompt missing nested prompt",
);
let missing_prompt = match missing_prompt {
Ok(result) => {
assert!(
result.is_error,
"changing only the nested prompt name must stay a handler-visible refusal: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("public-http-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy compose_prompt must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_resource_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let upstream = spawn_modern_compose_and_state_http_server();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-resource-compose", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy resource-compose gateway");
let missing = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, &format!("ext/{PUBLIC_HTTP_COMPOSE_RESOURCE_URI}")),
)
.expect_err("modern as_proxy must keep the compose resource URI unprefixed");
let _ = missing;
let composed = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_COMPOSE_RESOURCE_URI),
)
.expect("as_proxy must forward unprefixed compose resources/read onto the live HTTP upstream");
assert!(
composed.contents.iter().any(|content| match content {
EmbeddedResourceContents::Text { text, .. } => {
text == "compose:tool:alpha|resource:deterministic"
}
_ => false,
}),
"as_proxy resources/read must retain the nested tool text and resource: {composed:?}"
);
let missing_tool = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_COMPOSE_MISSING_TOOL_RESOURCE_URI),
);
let missing_tool = match missing_tool {
Ok(result) => panic!(
"changing only the nested tool name must stay a handler-visible refusal: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("compose-nested-tool"),
"as_proxy resource compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_resource_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let upstream = spawn_legacy_compose_http_server();
let gateway = spawn_legacy_as_proxy_instructions_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-resource-compose",
);
let prefixed = format!("child/{PUBLIC_HTTP_COMPOSE_RESOURCE_URI}");
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy resource compose unprefixed",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_COMPOSE_RESOURCE_URI.to_owned(),
meta: None,
},
),
)
.expect_err("changing only the missing prefix must not reach the unprefixed compose resource");
let _ = missing;
let composed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy resource compose",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: prefixed,
meta: None,
},
),
)
.expect("as_proxy must forward prefixed compose resources/read onto the live exact-2024 HTTP upstream");
let composed =
serde_json::to_value(composed).expect("the exact-2024 HTTP resource result serializes");
assert_eq!(
composed["contents"][0]["text"],
json!("compose:tool:alpha|resource:deterministic"),
"as_proxy resources/read must retain the nested tool text and resource: {composed}"
);
let missing_tool = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy resource compose missing nested tool",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: format!("child/{PUBLIC_HTTP_COMPOSE_MISSING_TOOL_RESOURCE_URI}"),
meta: None,
},
),
);
let missing_tool = match missing_tool {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("compose-nested-tool"),
"as_proxy resource compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_prompt_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let upstream = spawn_modern_compose_and_state_http_server();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-prompt-compose-handler", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy prompt-compose gateway");
let prefixed = format!("ext/{PUBLIC_HTTP_COMPOSE_PROMPT_NAME}");
let missing = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
PUBLIC_HTTP_COMPOSE_PROMPT_NAME,
HashMap::from([("value".to_owned(), "alpha".to_owned())]),
),
)
.expect_err("changing only the prompt name must not reach the unprefixed compose prompt");
let _ = missing;
let composed = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
&prefixed,
HashMap::from([("value".to_owned(), "alpha".to_owned())]),
),
)
.expect("as_proxy must forward prefixed compose prompts/get onto the live HTTP upstream");
assert!(
composed
.messages
.iter()
.any(|message| match &message.content {
ContentBlock::Text { text, .. } => {
text == "compose:tool:alpha|resource:deterministic"
}
_ => false,
}),
"as_proxy prompts/get must retain the nested tool text and resource: {composed:?}"
);
let missing_tool = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
&prefixed,
HashMap::from([
("value".to_owned(), "alpha".to_owned()),
("tool".to_owned(), "public-http-e2e-missing".to_owned()),
]),
),
);
let missing_tool = match missing_tool {
Ok(result) => panic!(
"changing only the nested tool name must stay a handler-visible refusal: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("compose-nested-tool"),
"as_proxy prompt compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_prompt_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let upstream = spawn_legacy_compose_http_server();
let gateway = spawn_legacy_as_proxy_instructions_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-prompt-compose-handler",
);
let prefixed = format!("child/{PUBLIC_HTTP_COMPOSE_PROMPT_NAME}");
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy prompt compose unprefixed",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: PUBLIC_HTTP_COMPOSE_PROMPT_NAME.to_owned(),
arguments: Some(HashMap::from([("value".to_owned(), "alpha".to_owned())])),
meta: None,
},
),
)
.expect_err("changing only the missing prefix must not reach the unprefixed compose prompt");
let _ = missing;
let composed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy prompt compose",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: prefixed.clone(),
arguments: Some(HashMap::from([("value".to_owned(), "alpha".to_owned())])),
meta: None,
},
),
)
.expect(
"as_proxy must forward prefixed compose prompts/get onto the live exact-2024 HTTP upstream",
);
let composed =
serde_json::to_value(composed).expect("the exact-2024 HTTP prompt result serializes");
assert_eq!(
composed["messages"][0]["content"]["text"],
json!("compose:tool:alpha|resource:deterministic"),
"as_proxy prompts/get must retain the nested tool text and resource: {composed}"
);
let missing_tool = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy prompt compose missing nested tool",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: prefixed,
arguments: Some(HashMap::from([
("value".to_owned(), "alpha".to_owned()),
("tool".to_owned(), "public-http-e2e-missing".to_owned()),
])),
meta: None,
},
),
);
let missing_tool = match missing_tool {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("compose-nested-tool"),
"as_proxy prompt compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_from_prompt_composes_nested_prompt() {
let cx = Cx::for_request();
let upstream = spawn_modern_compose_prompt_http_server();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-from-prompt", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy from-prompt gateway");
let prefixed = format!("ext/{PUBLIC_HTTP_FROM_PROMPT_NAME}");
let missing = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
PUBLIC_HTTP_FROM_PROMPT_NAME,
HashMap::from([("subject".to_owned(), "alpha".to_owned())]),
),
)
.expect_err("changing only the prompt name must not reach the unprefixed from-prompt");
let _ = missing;
let composed = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
&prefixed,
HashMap::from([("subject".to_owned(), "alpha".to_owned())]),
),
)
.expect("as_proxy must forward prefixed from-prompt onto the live HTTP upstream");
assert!(
composed
.messages
.iter()
.any(|message| match &message.content {
ContentBlock::Text { text, .. } => text == "compose-prompt:prompt:alpha",
_ => false,
}),
"as_proxy prompts/get must retain the nested prompt text: {composed:?}"
);
let missing_prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
&prefixed,
HashMap::from([
("subject".to_owned(), "alpha".to_owned()),
("prompt".to_owned(), "public-http-e2e-missing".to_owned()),
]),
),
);
let missing_prompt = match missing_prompt {
Ok(result) => panic!(
"changing only the nested prompt name must stay a handler-visible refusal: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("public-http-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy from-prompt compose must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_from_prompt_composes_nested_prompt() {
let cx = Cx::for_request();
let upstream = spawn_legacy_compose_prompt_http_server();
let gateway = spawn_legacy_as_proxy_instructions_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-from-prompt",
);
let prefixed = format!("child/{PUBLIC_HTTP_FROM_PROMPT_NAME}");
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy from-prompt unprefixed",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: PUBLIC_HTTP_FROM_PROMPT_NAME.to_owned(),
arguments: Some(HashMap::from([("subject".to_owned(), "alpha".to_owned())])),
meta: None,
},
),
)
.expect_err("changing only the missing prefix must not reach the unprefixed from-prompt");
let _ = missing;
let composed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy from-prompt",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: prefixed.clone(),
arguments: Some(HashMap::from([("subject".to_owned(), "alpha".to_owned())])),
meta: None,
},
),
)
.expect("as_proxy must forward prefixed from-prompt onto the live exact-2024 HTTP upstream");
let composed =
serde_json::to_value(composed).expect("the exact-2024 HTTP prompt result serializes");
assert_eq!(
composed["messages"][0]["content"]["text"],
json!("compose-prompt:prompt:alpha"),
"as_proxy prompts/get must retain the nested prompt text: {composed}"
);
let missing_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy from-prompt missing nested prompt",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: prefixed,
arguments: Some(HashMap::from([
("subject".to_owned(), "alpha".to_owned()),
("prompt".to_owned(), "public-http-e2e-missing".to_owned()),
])),
meta: None,
},
),
);
let missing_prompt = match missing_prompt {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("public-http-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy from-prompt compose must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_forwards_inbound_completion_progress_marker() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_completion_progress_upstream();
let gateway = spawn_legacy_as_proxy_http_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-complete-progress",
);
let prefixed = format!("child/{PUBLIC_HTTP_PROMPT_NAME}");
let params = legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt { name: prefixed },
argument: legacy_2024::LegacyCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
meta: None,
};
runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy completion/complete before token",
client.complete(&cx, params.clone()),
)
.expect("prefixed prompt complete must complete without a progressToken");
let silent = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"omitting only the progressToken must keep as_proxy completion/complete silent: {silent:?}"
);
let mut unprefixed = params.clone();
unprefixed.reference = legacy_2024::LegacyCompletionReference::Prompt {
name: PUBLIC_HTTP_PROMPT_NAME.to_owned(),
};
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy completion unprefixed",
client.complete_with_progress_marker(
&cx,
unprefixed,
legacy_2024::ProgressMarker::from("http-legacy-as-proxy-complete-unprefixed"),
),
);
assert!(
missing.is_err(),
"changing only the prompt name must refuse before the unprefixed completion provider: {missing:?}"
);
let marker = legacy_2024::ProgressMarker::from("http-legacy-as-proxy-complete-progress");
let progressed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy completion/complete with token",
client.complete_with_progress_marker(&cx, params, marker.clone()),
)
.expect("as_proxy must forward an inbound progressToken onto prefixed completion/complete");
assert_eq!(
progressed.completion.values,
vec![PUBLIC_HTTP_COMPLETION_VALUE.to_owned()],
"as_proxy must still retain the upstream completion values: {progressed:?}"
);
let progress =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == marker
&& params.message.as_deref() == Some("completion-legacy-halfway")
)),
"exact-2024 as_proxy must retain upstream completion notifications/progress: {progress:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_sanitizes_prefixed_handler_panic() {
let cx = Cx::for_request();
let upstream = spawn_modern_as_proxy_panic_upstream();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-panic", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy panic gateway");
let prefixed = format!("ext/{PUBLIC_HTTP_PANIC_TOOL_NAME}");
let prefixed_fast = format!("ext/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_PANIC_TOOL_NAME, json!({})),
)
.expect_err("changing only the tool name must not reach the unprefixed panic tool");
let _ = missing;
let panicked = runtime_block_on_bounded(&cx, client.call_tool(&cx, &prefixed, json!({})));
let panicked = match panicked {
Ok(result) if result.is_error => format!("{result:?}"),
Err(error) => format!("{error:?}"),
Ok(result) => {
panic!("a panicking as_proxy tools/call must stay a protocol error: {result:?}")
}
};
assert!(
panicked.contains("Internal server error") || panicked.contains("InternalError"),
"as_proxy must sanitize an upstream handler panic: {panicked}"
);
assert!(
!panicked.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"as_proxy must not leak the upstream unwind payload: {panicked}"
);
let fast = runtime_block_on_bounded(&cx, client.call_tool(&cx, &prefixed_fast, json!({})))
.expect("changing only the tool must still be admitted after a sanitized as_proxy panic");
assert!(
fast.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"the prefixed fast peer tool must still complete after a sanitized as_proxy panic: {fast:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_sanitizes_prefixed_handler_panic() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_panic_upstream();
let gateway = spawn_legacy_as_proxy_http_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-panic",
);
let prefixed = format!("child/{PUBLIC_HTTP_PANIC_TOOL_NAME}");
let prefixed_fast = format!("child/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy panic unprefixed",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_PANIC_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
);
assert!(
missing.is_err(),
"changing only the missing prefix must not reach the unprefixed panic tool: {missing:?}"
);
let panicked = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy panic tools/call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed,
arguments: Some(json!({})),
meta: None,
},
),
);
let panicked = match panicked {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
panicked.contains("Internal server error") || panicked.contains("InternalError"),
"exact-2024 as_proxy must sanitize an upstream handler panic: {panicked}"
);
assert!(
!panicked.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"exact-2024 as_proxy must not leak the upstream unwind payload: {panicked}"
);
let fast = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy panic fast peer",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed_fast,
arguments: Some(json!({})),
meta: None,
},
),
)
.expect(
"changing only the tool must still be admitted after a sanitized exact-2024 as_proxy panic",
);
assert_eq!(
legacy_http_tool_text(&fast).as_deref(),
Some("fast"),
"the prefixed fast peer tool must still complete after a sanitized as_proxy panic: {fast:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_sanitizes_prefixed_handler_panic() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_configured(
false,
vec![("FASTMCP_PANIC_TOOL".to_owned(), "1".to_owned())],
);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-panic", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy panic gateway");
let missing = runtime_block_on_bounded(&cx, client.call_tool(&cx, "panic_probe", json!({})))
.expect_err("changing only the tool name must not reach the unprefixed stdio panic tool");
let _ = missing;
let panicked =
runtime_block_on_bounded(&cx, client.call_tool(&cx, "ext/panic_probe", json!({})));
let panicked = match panicked {
Ok(result) if result.is_error => format!("{result:?}"),
Err(error) => format!("{error:?}"),
Ok(result) => {
panic!("a panicking stdio as_proxy tools/call must stay a protocol error: {result:?}")
}
};
assert!(
panicked.contains("Internal server error") || panicked.contains("InternalError"),
"stdio as_proxy must sanitize an upstream handler panic: {panicked}"
);
assert!(
!panicked.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"stdio as_proxy must not leak the upstream unwind payload: {panicked}"
);
let peer = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({"message": "after-panic"})),
)
.expect("changing only the tool must still be admitted after a sanitized stdio as_proxy panic");
assert!(
peer.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "after-panic",
_ => false,
}),
"the prefixed stdio echo peer must still complete after a sanitized as_proxy panic: {peer:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_sanitizes_prefixed_handler_panic() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway_with_env(vec![(
"FASTMCP_PANIC_TOOL".to_owned(),
"1".to_owned(),
)]);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-panic",
);
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy panic unprefixed",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "panic_probe".to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
);
assert!(
missing.is_err(),
"changing only the missing prefix must not reach the unprefixed stdio panic tool: {missing:?}"
);
let panicked = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy panic tools/call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/panic_probe".to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
);
let panicked = match panicked {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
panicked.contains("Internal server error") || panicked.contains("InternalError"),
"exact-2024 stdio as_proxy must sanitize an upstream handler panic: {panicked}"
);
assert!(
!panicked.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"exact-2024 stdio as_proxy must not leak the upstream unwind payload: {panicked}"
);
let peer = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy panic echo peer",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/echo".to_owned(),
arguments: Some(json!({"message": "after-panic"})),
meta: None,
},
),
)
.expect("changing only the tool must still be admitted after a sanitized exact-2024 stdio as_proxy panic");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("after-panic"),
"the prefixed stdio echo peer must still complete after a sanitized as_proxy panic: {peer:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_sanitizes_prefixed_catalog_and_completion_panic() {
let cx = Cx::for_request();
let upstream = spawn_modern_as_proxy_panic_upstream();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-catalog-panic", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy catalog-panic gateway");
let prefixed_prompt = format!("ext/{PUBLIC_HTTP_PANIC_PROMPT_NAME}");
let prefixed_peer_prompt = format!("ext/{PUBLIC_HTTP_PROMPT_NAME}");
let prefixed_complete = format!("ext/{PUBLIC_HTTP_PROMPT_NAME}");
let prefixed_fast = format!("ext/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let missing_resource = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, &format!("ext/{PUBLIC_HTTP_PANIC_RESOURCE_URI}")),
)
.expect_err("modern as_proxy must not invent a prefixed panic resource URI");
let _ = missing_resource;
let panicked_resource = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_PANIC_RESOURCE_URI),
);
let panicked_resource = match panicked_resource {
Ok(result) => {
panic!("a panicking as_proxy resources/read must stay a protocol error: {result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
panicked_resource.contains("Internal server error")
|| panicked_resource.contains("InternalError"),
"as_proxy must sanitize an upstream resource panic: {panicked_resource}"
);
assert!(
!panicked_resource.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"as_proxy must not leak the upstream resource unwind payload: {panicked_resource}"
);
let missing_prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(&cx, PUBLIC_HTTP_PANIC_PROMPT_NAME, HashMap::new()),
)
.expect_err("changing only the prompt name must not reach the unprefixed panic prompt");
let _ = missing_prompt;
let panicked_prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(&cx, &prefixed_prompt, HashMap::new()),
);
let panicked_prompt = match panicked_prompt {
Ok(result) => {
panic!("a panicking as_proxy prompts/get must stay a protocol error: {result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
panicked_prompt.contains("Internal server error")
|| panicked_prompt.contains("InternalError"),
"as_proxy must sanitize an upstream prompt panic: {panicked_prompt}"
);
assert!(
!panicked_prompt.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"as_proxy must not leak the upstream prompt unwind payload: {panicked_prompt}"
);
let missing_complete = runtime_block_on_bounded(
&cx,
client.complete(
&cx,
modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: PUBLIC_HTTP_PROMPT_NAME.to_owned(),
title: "Public HTTP E2E Prompt".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
context: None,
},
),
)
.expect_err("changing only the completion prompt name must not reach the unprefixed provider");
let _ = missing_complete;
let panicked_complete = runtime_block_on_bounded(
&cx,
client.complete(
&cx,
modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: prefixed_complete,
title: "Public HTTP E2E Prompt".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
context: None,
},
),
);
let panicked_complete = match panicked_complete {
Ok(result) => panic!(
"a panicking as_proxy completion/complete must stay a protocol error: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
panicked_complete.contains("Internal server error")
|| panicked_complete.contains("InternalError"),
"as_proxy must sanitize an upstream completion panic: {panicked_complete}"
);
assert!(
!panicked_complete.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"as_proxy must not leak the upstream completion unwind payload: {panicked_complete}"
);
let peer_resource =
runtime_block_on_bounded(&cx, client.read_resource(&cx, PUBLIC_HTTP_RESOURCE_URI)).expect(
"changing only the resource must still be admitted after a sanitized as_proxy panic",
);
let peer_resource = format!("{peer_resource:?}");
assert!(
peer_resource.contains(PUBLIC_HTTP_RESOURCE_TEXT),
"the unprefixed peer resource must still complete after a sanitized as_proxy panic: {peer_resource}"
);
let peer_prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
&prefixed_peer_prompt,
HashMap::from([("subject".to_owned(), PUBLIC_HTTP_TOOL_ARGUMENT.to_owned())]),
),
)
.expect("changing only the prompt must still be admitted after a sanitized as_proxy panic");
let peer_prompt = format!("{peer_prompt:?}");
assert!(
peer_prompt.contains(PUBLIC_HTTP_PROMPT_TEXT),
"the prefixed peer prompt must still complete after a sanitized as_proxy panic: {peer_prompt}"
);
let fast = runtime_block_on_bounded(&cx, client.call_tool(&cx, &prefixed_fast, json!({})))
.expect(
"changing only the method must still admit tools/call after a sanitized as_proxy panic",
);
assert!(
fast.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"the prefixed fast peer tool must still complete after a sanitized as_proxy catalog panic: {fast:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_sanitizes_prefixed_catalog_and_completion_panic() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_panic_upstream();
let gateway = spawn_legacy_as_proxy_http_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-catalog-panic",
);
let prefixed_resource = format!("child/{PUBLIC_HTTP_PANIC_RESOURCE_URI}");
let prefixed_peer_resource = format!("child/{PUBLIC_HTTP_RESOURCE_URI}");
let prefixed_prompt = format!("child/{PUBLIC_HTTP_PANIC_PROMPT_NAME}");
let prefixed_peer_prompt = format!("child/{PUBLIC_HTTP_PROMPT_NAME}");
let prefixed_fast = format!("child/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let missing_resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy catalog panic unprefixed resource",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_PANIC_RESOURCE_URI.to_owned(),
meta: None,
},
),
);
assert!(
missing_resource.is_err(),
"changing only the missing prefix must not reach the unprefixed panic resource: {missing_resource:?}"
);
let panicked_resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy catalog panic resources/read",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: prefixed_resource,
meta: None,
},
),
);
let panicked_resource = match panicked_resource {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
panicked_resource.contains("Internal server error")
|| panicked_resource.contains("InternalError"),
"exact-2024 as_proxy must sanitize an upstream resource panic: {panicked_resource}"
);
assert!(
!panicked_resource.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"exact-2024 as_proxy must not leak the upstream resource unwind payload: {panicked_resource}"
);
let missing_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy catalog panic unprefixed prompt",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: PUBLIC_HTTP_PANIC_PROMPT_NAME.to_owned(),
arguments: None,
meta: None,
},
),
);
assert!(
missing_prompt.is_err(),
"changing only the missing prefix must not reach the unprefixed panic prompt: {missing_prompt:?}"
);
let panicked_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy catalog panic prompts/get",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: prefixed_prompt,
arguments: None,
meta: None,
},
),
);
let panicked_prompt = match panicked_prompt {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
panicked_prompt.contains("Internal server error")
|| panicked_prompt.contains("InternalError"),
"exact-2024 as_proxy must sanitize an upstream prompt panic: {panicked_prompt}"
);
assert!(
!panicked_prompt.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"exact-2024 as_proxy must not leak the upstream prompt unwind payload: {panicked_prompt}"
);
let missing_complete = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy completion panic unprefixed",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt {
name: PUBLIC_HTTP_PROMPT_NAME.to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
meta: None,
},
),
);
assert!(
missing_complete.is_err(),
"changing only the completion prompt name must refuse before the unprefixed provider: {missing_complete:?}"
);
let panicked_complete = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy completion panic complete",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt {
name: prefixed_peer_prompt.clone(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
meta: None,
},
),
);
let panicked_complete = match panicked_complete {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
panicked_complete.contains("Internal server error")
|| panicked_complete.contains("InternalError"),
"exact-2024 as_proxy must sanitize an upstream completion panic: {panicked_complete}"
);
assert!(
!panicked_complete.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"exact-2024 as_proxy must not leak the upstream completion unwind payload: {panicked_complete}"
);
let peer_resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy catalog panic peer resource",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: prefixed_peer_resource,
meta: None,
},
),
)
.expect("changing only the resource must still be admitted after a sanitized exact-2024 as_proxy panic");
let peer_resource = format!("{peer_resource:?}");
assert!(
peer_resource.contains(PUBLIC_HTTP_RESOURCE_TEXT),
"the prefixed peer resource must still complete after a sanitized as_proxy panic: {peer_resource}"
);
let peer_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy catalog panic peer prompt",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: prefixed_peer_prompt,
arguments: Some(HashMap::from([(
"subject".to_owned(),
PUBLIC_HTTP_TOOL_ARGUMENT.to_owned(),
)])),
meta: None,
},
),
)
.expect("changing only the prompt must still be admitted after a sanitized exact-2024 as_proxy panic");
let peer_prompt = format!("{peer_prompt:?}");
assert!(
peer_prompt.contains(PUBLIC_HTTP_PROMPT_TEXT),
"the prefixed peer prompt must still complete after a sanitized as_proxy panic: {peer_prompt}"
);
let fast = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy catalog panic fast peer",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed_fast,
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("changing only the method must still admit tools/call after a sanitized exact-2024 as_proxy panic");
assert_eq!(
legacy_http_tool_text(&fast).as_deref(),
Some("fast"),
"the prefixed fast peer tool must still complete after a sanitized as_proxy catalog panic: {fast:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_sanitizes_prefixed_catalog_and_completion_panic() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_configured(
false,
vec![
("FASTMCP_PANIC_CATALOG".to_owned(), "1".to_owned()),
("FASTMCP_PANIC_COMPLETE".to_owned(), "1".to_owned()),
],
);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-catalog-panic", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy catalog-panic gateway");
let missing_resource =
runtime_block_on_bounded(&cx, client.read_resource(&cx, "ext/info://panic"))
.expect_err("modern stdio as_proxy must not invent a prefixed panic resource URI");
let _ = missing_resource;
let panicked_resource =
runtime_block_on_bounded(&cx, client.read_resource(&cx, "info://panic"));
let panicked_resource = match panicked_resource {
Ok(result) => panic!(
"a panicking stdio as_proxy resources/read must stay a protocol error: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
panicked_resource.contains("Internal server error")
|| panicked_resource.contains("InternalError"),
"stdio as_proxy must sanitize an upstream resource panic: {panicked_resource}"
);
assert!(
!panicked_resource.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"stdio as_proxy must not leak the upstream resource unwind payload: {panicked_resource}"
);
let missing_prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(&cx, "panic_greeting", HashMap::new()),
)
.expect_err("changing only the prompt name must not reach the unprefixed stdio panic prompt");
let _ = missing_prompt;
let panicked_prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(&cx, "ext/panic_greeting", HashMap::new()),
);
let panicked_prompt = match panicked_prompt {
Ok(result) => {
panic!("a panicking stdio as_proxy prompts/get must stay a protocol error: {result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
panicked_prompt.contains("Internal server error")
|| panicked_prompt.contains("InternalError"),
"stdio as_proxy must sanitize an upstream prompt panic: {panicked_prompt}"
);
assert!(
!panicked_prompt.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"stdio as_proxy must not leak the upstream prompt unwind payload: {panicked_prompt}"
);
let missing_complete = runtime_block_on_bounded(
&cx,
client.complete(
&cx,
modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: "greeting".to_owned(),
title: "Greeting".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "name".to_owned(),
value: "World".to_owned(),
},
context: None,
},
),
)
.expect_err(
"changing only the completion prompt name must not reach the unprefixed stdio provider",
);
let _ = missing_complete;
let panicked_complete = runtime_block_on_bounded(
&cx,
client.complete(
&cx,
modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: "ext/greeting".to_owned(),
title: "Greeting".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "name".to_owned(),
value: "World".to_owned(),
},
context: None,
},
),
);
let panicked_complete = match panicked_complete {
Ok(result) => panic!(
"a panicking stdio as_proxy completion/complete must stay a protocol error: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
panicked_complete.contains("Internal server error")
|| panicked_complete.contains("InternalError"),
"stdio as_proxy must sanitize an upstream completion panic: {panicked_complete}"
);
assert!(
!panicked_complete.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"stdio as_proxy must not leak the upstream completion unwind payload: {panicked_complete}"
);
let peer_resource = runtime_block_on_bounded(&cx, client.read_resource(&cx, "info://server"))
.expect("changing only the resource must still be admitted after a sanitized stdio as_proxy panic");
let peer_resource = format!("{peer_resource:?}");
assert!(
peer_resource.contains("echo-server") || peer_resource.contains("info://server"),
"the unprefixed stdio peer resource must still complete after a sanitized as_proxy panic: {peer_resource}"
);
let peer_prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
"ext/greeting",
HashMap::from([("name".to_owned(), "World".to_owned())]),
),
)
.expect(
"changing only the prompt must still be admitted after a sanitized stdio as_proxy panic",
);
let peer_prompt = format!("{peer_prompt:?}");
assert!(
peer_prompt.contains("World") || peer_prompt.contains("greet"),
"the prefixed stdio peer prompt must still complete after a sanitized as_proxy panic: {peer_prompt}"
);
let peer = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({"message": "after-catalog-panic"})),
)
.expect("changing only the method must still admit tools/call after a sanitized stdio as_proxy panic");
assert!(
peer.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "after-catalog-panic",
_ => false,
}),
"the prefixed stdio echo peer must still complete after a sanitized as_proxy catalog panic: {peer:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_sanitizes_prefixed_catalog_and_completion_panic() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway_with_env(vec![
("FASTMCP_PANIC_CATALOG".to_owned(), "1".to_owned()),
("FASTMCP_PANIC_COMPLETE".to_owned(), "1".to_owned()),
]);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-catalog-panic",
);
let missing_resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy catalog panic unprefixed resource",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "info://panic".to_owned(),
meta: None,
},
),
);
assert!(
missing_resource.is_err(),
"changing only the missing prefix must not reach the unprefixed stdio panic resource: {missing_resource:?}"
);
let panicked_resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy catalog panic resources/read",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "ext/info://panic".to_owned(),
meta: None,
},
),
);
let panicked_resource = match panicked_resource {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
panicked_resource.contains("Internal server error")
|| panicked_resource.contains("InternalError"),
"exact-2024 stdio as_proxy must sanitize an upstream resource panic: {panicked_resource}"
);
assert!(
!panicked_resource.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"exact-2024 stdio as_proxy must not leak the upstream resource unwind payload: {panicked_resource}"
);
let missing_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy catalog panic unprefixed prompt",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: "panic_greeting".to_owned(),
arguments: None,
meta: None,
},
),
);
assert!(
missing_prompt.is_err(),
"changing only the missing prefix must not reach the unprefixed stdio panic prompt: {missing_prompt:?}"
);
let panicked_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy catalog panic prompts/get",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: "ext/panic_greeting".to_owned(),
arguments: None,
meta: None,
},
),
);
let panicked_prompt = match panicked_prompt {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
panicked_prompt.contains("Internal server error")
|| panicked_prompt.contains("InternalError"),
"exact-2024 stdio as_proxy must sanitize an upstream prompt panic: {panicked_prompt}"
);
assert!(
!panicked_prompt.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"exact-2024 stdio as_proxy must not leak the upstream prompt unwind payload: {panicked_prompt}"
);
let missing_complete = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy completion panic unprefixed",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt {
name: "greeting".to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "name".to_owned(),
value: "World".to_owned(),
},
meta: None,
},
),
);
assert!(
missing_complete.is_err(),
"changing only the completion prompt name must refuse before the unprefixed stdio provider: {missing_complete:?}"
);
let panicked_complete = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy completion panic complete",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt {
name: "ext/greeting".to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "name".to_owned(),
value: "World".to_owned(),
},
meta: None,
},
),
);
let panicked_complete = match panicked_complete {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
panicked_complete.contains("Internal server error")
|| panicked_complete.contains("InternalError"),
"exact-2024 stdio as_proxy must sanitize an upstream completion panic: {panicked_complete}"
);
assert!(
!panicked_complete.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"exact-2024 stdio as_proxy must not leak the upstream completion unwind payload: {panicked_complete}"
);
let peer_resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy catalog panic peer resource",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "ext/info://server".to_owned(),
meta: None,
},
),
)
.expect("changing only the resource must still be admitted after a sanitized exact-2024 stdio as_proxy panic");
let peer_resource = format!("{peer_resource:?}");
assert!(
peer_resource.contains("echo-server") || peer_resource.contains("info://server"),
"the prefixed stdio peer resource must still complete after a sanitized as_proxy panic: {peer_resource}"
);
let peer_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy catalog panic peer prompt",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: "ext/greeting".to_owned(),
arguments: Some(HashMap::from([("name".to_owned(), "World".to_owned())])),
meta: None,
},
),
)
.expect("changing only the prompt must still be admitted after a sanitized exact-2024 stdio as_proxy panic");
let peer_prompt = format!("{peer_prompt:?}");
assert!(
peer_prompt.contains("World") || peer_prompt.contains("greet"),
"the prefixed stdio peer prompt must still complete after a sanitized as_proxy panic: {peer_prompt}"
);
let peer = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy catalog panic echo peer",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/echo".to_owned(),
arguments: Some(json!({"message": "after-catalog-panic"})),
meta: None,
},
),
)
.expect("changing only the method must still admit tools/call after a sanitized exact-2024 stdio as_proxy panic");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("after-catalog-panic"),
"the prefixed stdio echo peer must still complete after a sanitized as_proxy catalog panic: {peer:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_gateway_cache_hits_prefixed_allowlisted_tool() {
let cx = Cx::for_request();
let upstream = spawn_modern_as_proxy_counting_upstream();
let prefixed = format!("ext/{PUBLIC_HTTP_TOOL_NAME}");
let gateway = spawn_modern_http_identity_proxy_gateway_configured(
upstream.address(),
None,
false,
None,
Some(vec![prefixed.clone()]),
false,
false,
false,
false,
None,
);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-cache", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy cache gateway");
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.expect_err("changing only the tool name must not reach the unprefixed counting tool");
let _ = missing;
let first = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.expect("the first allowlisted prefixed tools/call must miss and reach the upstream");
assert!(
first.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the first as_proxy cache miss must return the upstream handler result: {first:?}"
);
assert_eq!(
upstream.handler_call_snapshot().tool,
1,
"the first allowlisted prefixed tools/call must invoke the upstream counting handler"
);
let cached = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.expect("the second identical prefixed tools/call must be a gateway cache hit");
assert!(
cached.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the as_proxy cache hit must keep the first complete result: {cached:?}"
);
assert_eq!(
upstream.handler_call_snapshot().tool,
1,
"a gateway cache hit must not invoke the upstream counting handler again"
);
let missed = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, &prefixed, json!({"value": "beta"})),
)
.expect("changing only the arguments must miss the gateway cache");
assert!(
missed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:beta",
_ => false,
}),
"the as_proxy cache miss must reach the upstream with the new arguments: {missed:?}"
);
assert_eq!(
upstream.handler_call_snapshot().tool,
2,
"a different-arguments prefixed tools/call must invoke the upstream counting handler"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_gateway_cache_hits_prefixed_allowlisted_tool() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_counting_upstream();
let prefixed = format!("child/{PUBLIC_HTTP_TOOL_NAME}");
let gateway = spawn_legacy_as_proxy_http_gateway_configured(
upstream.address(),
Some(vec![prefixed.clone()]),
false,
PUBLIC_HTTP_TOOL_NAME,
false,
false,
false,
None,
);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-cache",
);
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy cache unprefixed",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_TOOL_NAME.to_owned(),
arguments: Some(json!({"value": "alpha"})),
meta: None,
},
),
);
assert!(
missing.is_err(),
"changing only the missing prefix must not reach the unprefixed counting tool: {missing:?}"
);
let first = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy cache miss",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed.clone(),
arguments: Some(json!({"value": "alpha"})),
meta: None,
},
),
)
.expect("the first allowlisted prefixed tools/call must miss and reach the upstream");
assert_eq!(
legacy_http_tool_text(&first).as_deref(),
Some("tool:alpha"),
"the first exact-2024 as_proxy cache miss must return the upstream handler result: {first:?}"
);
assert_eq!(
upstream.handler_call_snapshot().tool,
1,
"the first allowlisted prefixed tools/call must invoke the upstream counting handler"
);
let cached = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy cache hit",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed.clone(),
arguments: Some(json!({"value": "alpha"})),
meta: None,
},
),
)
.expect("the second identical prefixed tools/call must be a gateway cache hit");
assert_eq!(
legacy_http_tool_text(&cached).as_deref(),
Some("tool:alpha"),
"the exact-2024 as_proxy cache hit must keep the first complete result: {cached:?}"
);
assert_eq!(
upstream.handler_call_snapshot().tool,
1,
"a gateway cache hit must not invoke the upstream counting handler again"
);
let missed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy cache arg miss",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed,
arguments: Some(json!({"value": "beta"})),
meta: None,
},
),
)
.expect("changing only the arguments must miss the gateway cache");
assert_eq!(
legacy_http_tool_text(&missed).as_deref(),
Some("tool:beta"),
"the exact-2024 as_proxy cache miss must reach the upstream with the new arguments: {missed:?}"
);
assert_eq!(
upstream.handler_call_snapshot().tool,
2,
"a different-arguments prefixed tools/call must invoke the upstream counting handler"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_gateway_rate_limit_refuses_second_prefixed_call() {
let cx = Cx::for_request();
let upstream = spawn_modern_as_proxy_counting_upstream();
let gateway = spawn_modern_http_identity_proxy_gateway_configured(
upstream.address(),
None,
false,
None,
None,
true,
false,
false,
false,
None,
);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-rate-limit", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy rate-limit gateway");
let prefixed = format!("ext/{PUBLIC_HTTP_TOOL_NAME}");
let first = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.expect("the first prefixed tools/call must be admitted by the gateway rate limiter");
assert!(
first.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the first as_proxy rate-limited tools/call must reach the upstream: {first:?}"
);
let limited = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.expect_err("a second prefixed tools/call must be refused by the gateway rate limiter");
let limited = format!("{limited:?}");
assert!(
limited.contains("Rate limit exceeded"),
"the refused second as_proxy tools/call must keep the rate-limit error: {limited}"
);
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("changing only the method must still be admitted by the gateway rate limiter");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed),
"tools/list must stay callable after prefixed tools/call is rate-limited: {listed:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_gateway_sliding_window_refuses_second_prefixed_call() {
let cx = Cx::for_request();
let upstream = spawn_modern_as_proxy_counting_upstream();
let gateway = spawn_modern_http_identity_proxy_gateway_configured(
upstream.address(),
None,
false,
None,
None,
false,
true,
false,
false,
None,
);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-sliding", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy sliding-window gateway");
let prefixed = format!("ext/{PUBLIC_HTTP_TOOL_NAME}");
let first = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.expect("the first prefixed tools/call must be admitted by the gateway sliding window");
assert!(
first.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the first as_proxy sliding-window tools/call must reach the upstream: {first:?}"
);
let limited = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.expect_err("a second prefixed tools/call must be refused by the gateway sliding window");
let limited = format!("{limited:?}");
assert!(
limited.contains("Rate limit exceeded"),
"the refused second as_proxy tools/call must keep the sliding-window error: {limited}"
);
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("changing only the method must still be admitted by the gateway sliding window");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed),
"tools/list must stay callable after prefixed tools/call is sliding-window limited: {listed:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_gateway_static_token_refuses_missing_and_wrong() {
const MAX_NATIVE_HTTP_RESPONSE_BYTES: usize = 1 << 20;
fn exchange(address: SocketAddr, authorization: Option<&str>, body: &[u8]) -> Vec<u8> {
let mut stream = std::net::TcpStream::connect_timeout(&address, HTTP_OPERATION_BOUND)
.expect("native HTTP client connects to the as_proxy auth gateway");
stream
.set_read_timeout(Some(HTTP_OPERATION_BOUND))
.expect("native HTTP client read deadline is configured");
stream
.set_write_timeout(Some(HTTP_OPERATION_BOUND))
.expect("native HTTP client write deadline is configured");
let authorization_header =
authorization.map_or_else(String::new, |value| format!("Authorization: {value}\r\n"));
let prefixed = format!("ext/{PUBLIC_HTTP_TOOL_NAME}");
let request = format!(
"POST /mcp HTTP/1.1\r\nHost: {address}\r\n{authorization_header}Accept: application/json\r\nContent-Type: application/json\r\nMCP-Protocol-Version: {}\r\nMcp-Method: tools/call\r\nMcp-Name: {prefixed}\r\nContent-Length: {}\r\n\r\n",
modern::PROTOCOL_VERSION,
body.len(),
);
stream
.write_all(request.as_bytes())
.and_then(|()| stream.write_all(body))
.expect("native HTTP request commits to the as_proxy auth gateway");
let mut response = Vec::new();
let mut buffer = [0_u8; 8 * 1024];
loop {
let read = stream
.read(&mut buffer)
.expect("native HTTP response reads within its configured deadline");
if read == 0 {
break;
}
assert!(
response
.len()
.checked_add(read)
.is_some_and(|size| size <= MAX_NATIVE_HTTP_RESPONSE_BYTES),
"native HTTP response exceeds the test's bounded response budget"
);
response.extend_from_slice(&buffer[..read]);
}
response
}
fn response_headers(response: &[u8]) -> &str {
let header_end = response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("native HTTP response contains a complete header terminator");
std::str::from_utf8(&response[..header_end])
.expect("native HTTP response headers are ASCII")
}
fn response_json_body(response: &[u8]) -> serde_json::Value {
let header_end = response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("native HTTP response contains a complete header terminator");
let headers = std::str::from_utf8(&response[..header_end])
.expect("native HTTP response headers are ASCII");
let mut content_length = None;
let mut chunked = false;
for header in headers.lines().skip(1) {
let (name, value) = header
.split_once(':')
.expect("native HTTP response header has a field delimiter");
if name.eq_ignore_ascii_case("content-length") {
content_length = Some(
value
.trim()
.parse::<usize>()
.expect("native HTTP Content-Length is a valid byte count"),
);
}
if name.eq_ignore_ascii_case("transfer-encoding")
&& value
.split(',')
.any(|coding| coding.trim().eq_ignore_ascii_case("chunked"))
{
chunked = true;
}
}
let body = &response[header_end + 4..];
let decoded = if chunked {
let mut cursor = 0;
let mut decoded = Vec::new();
loop {
let size_end = body[cursor..]
.windows(2)
.position(|window| window == b"\r\n")
.map(|offset| cursor + offset)
.expect("chunked response contains a complete chunk-size line");
let size_line = std::str::from_utf8(&body[cursor..size_end])
.expect("chunked response chunk size is ASCII");
let size =
usize::from_str_radix(size_line.split(';').next().unwrap_or_default(), 16)
.expect("chunked response chunk size is hexadecimal");
cursor = size_end + 2;
if size == 0 {
return serde_json::from_slice(&decoded)
.expect("authenticated as_proxy tool response is JSON-RPC");
}
let chunk_end = cursor
.checked_add(size)
.expect("chunked response chunk length does not overflow");
decoded.extend_from_slice(&body[cursor..chunk_end]);
cursor = chunk_end + 2;
}
} else {
let content_length = content_length
.expect("native HTTP response uses Content-Length or chunked framing");
body[..content_length].to_vec()
};
serde_json::from_slice(&decoded).expect("authenticated as_proxy tool response is JSON-RPC")
}
let upstream = spawn_modern_as_proxy_counting_upstream();
let gateway = spawn_modern_http_identity_proxy_gateway_configured(
upstream.address(),
None,
false,
None,
None,
false,
false,
true,
false,
None,
);
let prefixed = format!("ext/{PUBLIC_HTTP_TOOL_NAME}");
let tool = JsonRpcRequest::new(
"tools/call",
Some(json!({
"name": prefixed,
"arguments": {"value": "alpha"},
"_meta": {
"io.modelcontextprotocol/protocolVersion": modern::PROTOCOL_VERSION,
"io.modelcontextprotocol/clientCapabilities": {
"extensions": {
"io.modelcontextprotocol/tasks": {}
}
},
},
})),
2_i64,
);
let tool_body = serde_json::to_vec(&tool).expect("as_proxy auth tool request serializes");
let missing = exchange(gateway.address(), None, &tool_body);
assert!(
missing.starts_with(b"HTTP/1.1 401"),
"missing Authorization must be refused before the as_proxy gateway dispatches: {}",
String::from_utf8_lossy(&missing)
);
assert!(
response_headers(&missing).lines().any(|header| header
.to_ascii_lowercase()
.starts_with("www-authenticate:")
&& header.to_ascii_lowercase().contains("bearer")),
"the missing-token as_proxy challenge must advertise Bearer: {}",
String::from_utf8_lossy(&missing)
);
assert_eq!(
upstream.handler_call_snapshot().tool,
0,
"missing Authorization must not reach the as_proxy upstream"
);
let wrong = exchange(gateway.address(), Some("Bearer beta"), &tool_body);
assert!(
wrong.starts_with(b"HTTP/1.1 401"),
"changing only the bearer token must be refused before as_proxy dispatch: {}",
String::from_utf8_lossy(&wrong)
);
assert_eq!(
upstream.handler_call_snapshot().tool,
0,
"a wrong bearer token must not reach the as_proxy upstream"
);
let admitted = exchange(gateway.address(), Some("Bearer alpha"), &tool_body);
assert!(
admitted.starts_with(b"HTTP/1.1 200"),
"the matching bearer token must admit prefixed tools/call: {}",
String::from_utf8_lossy(&admitted)
);
let admitted_json = response_json_body(&admitted);
let text = admitted_json["result"]["content"]
.as_array()
.into_iter()
.flatten()
.find_map(|block| block["text"].as_str())
.unwrap_or_default();
assert_eq!(
text, "tool:alpha",
"the matching as_proxy bearer token must reach the prefixed upstream: {admitted_json}"
);
assert_eq!(
upstream.handler_call_snapshot().tool,
1,
"only the matching bearer token may invoke the as_proxy upstream"
);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_gateway_session_state_is_request_local_across_posts() {
let cx = Cx::for_request();
let upstream = spawn_modern_as_proxy_state_upstream();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-state", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy session-state gateway");
let prefixed = format!("ext/{PUBLIC_HTTP_STATE_TOOL_NAME}");
let written = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, &prefixed, json!({"action": "write", "value": "alpha"})),
)
.expect("as_proxy must write request-local session state on the upstream");
assert!(
written.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "state:alpha",
_ => false,
}),
"set_state then get_state on the same as_proxy POST must retain the value: {written:?}"
);
let later_read = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, &prefixed, json!({"action": "read"})),
)
.expect("a later as_proxy POST must still be admitted");
assert!(
later_read.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "state:missing",
_ => false,
}),
"changing only the POST must not reuse the previous as_proxy request-local bag: {later_read:?}"
);
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_STATE_TOOL_NAME, json!({"action": "read"})),
)
.expect_err("changing only the tool name must not reach the unprefixed state tool");
let _ = missing;
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_gateway_strict_input_refuses_unknown_property() {
let cx = Cx::for_request();
let upstream = spawn_modern_as_proxy_counting_upstream();
let strict_gateway = spawn_modern_http_identity_proxy_gateway_configured(
upstream.address(),
None,
false,
None,
None,
false,
false,
false,
true,
None,
);
let lenient_gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let prefixed = format!("ext/{PUBLIC_HTTP_TOOL_NAME}");
let mut strict = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-strict-on", "1.0.0")
.connect_http_with_cx(public_http_target(strict_gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the strict as_proxy gateway");
let admitted = runtime_block_on_bounded(
&cx,
strict.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.expect("strict as_proxy must still admit declared arguments");
assert!(
admitted.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"declared arguments must reach the prefixed upstream under gateway strict validation: {admitted:?}"
);
let refused = runtime_block_on_bounded(
&cx,
strict.call_tool(&cx, &prefixed, json!({"value": "alpha", "extra": 1})),
)
.expect("strict as_proxy returns a complete tools/call result, not a transport failure");
assert!(
refused.is_error,
"an unknown property must become a tool-level error when gateway strict validation is on: {refused:?}"
);
assert!(
refused.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text.contains("do not match the declared input schema")
|| text.contains("additional property")
}
_ => false,
}),
"the as_proxy strict refusal must name the input-schema mismatch: {refused:?}"
);
drop(strict);
strict_gateway.shutdown();
let mut lenient = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-strict-off", "1.0.0")
.connect_http_with_cx(public_http_target(lenient_gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the lenient as_proxy gateway");
let extra = runtime_block_on_bounded(
&cx,
lenient.call_tool(&cx, &prefixed, json!({"value": "alpha", "extra": 1})),
)
.expect("lenient as_proxy must admit the same extra property");
assert!(
extra.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"changing only the gateway strict flag must admit the extra property: {extra:?}"
);
drop(lenient);
lenient_gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_gateway_static_token_refuses_missing_and_wrong() {
let upstream = spawn_legacy_as_proxy_counting_upstream();
let gateway = spawn_legacy_as_proxy_http_gateway_configured(
upstream.address(),
None,
false,
PUBLIC_HTTP_TOOL_NAME,
false,
true,
false,
None,
);
let prefixed = format!("child/{PUBLIC_HTTP_TOOL_NAME}");
let (_missing_stream, missing) =
write_legacy_native_http(gateway.address(), "GET", "/sse", None, &[], false);
assert_legacy_bearer_challenge(&missing, "missing Authorization on as_proxy GET /sse");
assert_eq!(
upstream.handler_call_snapshot().tool,
0,
"missing Authorization must not reach the as_proxy upstream"
);
let (_wrong_stream, wrong) = write_legacy_native_http(
gateway.address(),
"GET",
"/sse",
Some("Bearer beta"),
&[],
false,
);
assert_legacy_bearer_challenge(&wrong, "wrong bearer on as_proxy GET /sse");
assert_eq!(
upstream.handler_call_snapshot().tool,
0,
"a wrong bearer token must not reach the as_proxy upstream"
);
let (mut sse, opened, messages) = initialize_legacy_native_session(
gateway.address(),
Some("Bearer alpha"),
"e2e-public-http-legacy-as-proxy-auth",
);
let tool = JsonRpcRequest::new(
"tools/call",
Some(json!({
"name": prefixed,
"arguments": {"value": "alpha"},
})),
2_i64,
);
let tool_body = serde_json::to_vec(&tool).expect("exact-2024 as_proxy tools/call serializes");
let (_missing_post, missing_post) = write_legacy_native_http(
gateway.address(),
"POST",
&messages,
None,
&tool_body,
false,
);
assert_legacy_bearer_challenge(
&missing_post,
"missing Authorization on as_proxy POST /messages",
);
assert_eq!(
upstream.handler_call_snapshot().tool,
0,
"missing Authorization on POST /messages must not reach the as_proxy upstream"
);
let (_wrong_post, wrong_post) = write_legacy_native_http(
gateway.address(),
"POST",
&messages,
Some("Bearer beta"),
&tool_body,
false,
);
assert_legacy_bearer_challenge(&wrong_post, "wrong bearer on as_proxy POST /messages");
assert_eq!(
upstream.handler_call_snapshot().tool,
0,
"a wrong bearer token on POST /messages must not reach the as_proxy upstream"
);
let (_tool_stream, tool_response) = write_legacy_native_http(
gateway.address(),
"POST",
&messages,
Some("Bearer alpha"),
&tool_body,
false,
);
assert!(
tool_response.starts_with(b"HTTP/1.1 202") || tool_response.starts_with(b"HTTP/1.1 200"),
"the matching bearer token must admit prefixed as_proxy tools/call: {}",
String::from_utf8_lossy(&tool_response)
);
let mut sse_body = opened;
let retained = read_legacy_sse_until(&mut sse, &mut sse_body, "tool:alpha");
assert!(
retained.contains("tool:alpha"),
"the matching exact-2024 as_proxy bearer token must reach the prefixed upstream: {retained}"
);
assert_eq!(
upstream.handler_call_snapshot().tool,
1,
"only the matching bearer token may invoke the as_proxy upstream"
);
drop(sse);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_gateway_session_state_uses_shared_upstream_bag() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_state_upstream();
let gateway = spawn_legacy_as_proxy_http_gateway_configured(
upstream.address(),
None,
false,
PUBLIC_HTTP_STATE_TOOL_NAME,
false,
false,
false,
None,
);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-state",
);
let mut peer = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-state-peer",
);
let prefixed = format!("child/{PUBLIC_HTTP_STATE_TOOL_NAME}");
let written = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy session state write",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed.clone(),
arguments: Some(json!({"action": "write", "value": "alpha"})),
meta: None,
},
),
)
.expect("exact-2024 as_proxy must write session state on the upstream binding");
assert_eq!(
legacy_http_tool_text(&written).as_deref(),
Some("state:alpha"),
"set_state then get_state on the same as_proxy write must retain the value: {written:?}"
);
let later_read = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy session state later read",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed.clone(),
arguments: Some(json!({"action": "read"})),
meta: None,
},
),
)
.expect("a later exact-2024 as_proxy tools/call must still be admitted");
assert_eq!(
legacy_http_tool_text(&later_read).as_deref(),
Some("state:alpha"),
"the as_proxy upstream binding must reuse the previous bag: {later_read:?}"
);
let peer_read = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy session state peer read",
peer.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed,
arguments: Some(json!({"action": "read"})),
meta: None,
},
),
)
.expect("a peer inbound SSE session must still be admitted");
assert_eq!(
legacy_http_tool_text(&peer_read).as_deref(),
Some("state:alpha"),
"as_proxy must keep one upstream session bag across inbound clients: {peer_read:?}"
);
drop(client);
drop(peer);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_gateway_strict_input_refuses_unknown_property() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_counting_upstream();
let strict_gateway = spawn_legacy_as_proxy_http_gateway_configured(
upstream.address(),
None,
false,
PUBLIC_HTTP_TOOL_NAME,
false,
false,
true,
None,
);
let lenient_gateway = spawn_legacy_as_proxy_http_gateway_configured(
upstream.address(),
None,
false,
PUBLIC_HTTP_TOOL_NAME,
false,
false,
false,
None,
);
let prefixed = format!("child/{PUBLIC_HTTP_TOOL_NAME}");
let mut strict = connect_legacy_http_client(
&cx,
strict_gateway.address(),
"e2e-public-http-legacy-as-proxy-strict-on",
);
let admitted = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy strict declared",
strict.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed.clone(),
arguments: Some(json!({"value": "alpha"})),
meta: None,
},
),
)
.expect("strict as_proxy must still admit declared arguments");
assert_eq!(
legacy_http_tool_text(&admitted).as_deref(),
Some("tool:alpha"),
"declared arguments must reach the prefixed upstream under gateway strict validation: {admitted:?}"
);
let refused = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy strict extra-property",
strict.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed.clone(),
arguments: Some(json!({"value": "alpha", "extra": 1})),
meta: None,
},
),
);
let refused = match refused {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
refused.contains("do not match the declared input schema")
|| refused.contains("additional property")
|| refused.contains("unknown"),
"the exact-2024 as_proxy strict refusal must name the input-schema mismatch: {refused}"
);
drop(strict);
strict_gateway.shutdown();
let mut lenient = connect_legacy_http_client(
&cx,
lenient_gateway.address(),
"e2e-public-http-legacy-as-proxy-strict-off",
);
let extra = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy lenient extra-property",
lenient.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed,
arguments: Some(json!({"value": "alpha", "extra": 1})),
meta: None,
},
),
)
.expect("lenient as_proxy must admit the same extra property");
assert_eq!(
legacy_http_tool_text(&extra).as_deref(),
Some("tool:alpha"),
"changing only the gateway strict flag must admit the extra property: {extra:?}"
);
drop(lenient);
lenient_gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_gateway_list_page_size_pages_prefixed_tools() {
let cx = Cx::for_request();
let upstream = spawn_modern_as_proxy_counting_upstream();
let paged = spawn_modern_http_identity_proxy_gateway_configured(
upstream.address(),
None,
false,
None,
None,
false,
false,
false,
false,
Some(1),
);
let default_gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let prefixed = format!("ext/{PUBLIC_HTTP_TOOL_NAME}");
let prefixed_fast = format!("ext/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-page-size", "1.0.0")
.connect_http_with_cx(public_http_target(paged.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the paged as_proxy gateway");
let first = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("gateway list_page_size(1) must page prefixed tools");
assert_eq!(
first.tools.len(),
1,
"as_proxy gateway list_page_size(1) must create a real continuation: {first:?}"
);
let first_name = first.tools[0].name.clone();
assert!(
first_name == prefixed || first_name == prefixed_fast,
"the first as_proxy page must retain a prefixed catalog tool: {first:?}"
);
let cursor = first
.next_cursor
.clone()
.expect("the first as_proxy page must carry an opaque cursor");
let second = runtime_block_on_bounded(&cx, client.list_tools(&cx, Some(cursor.as_str())))
.expect("the as_proxy cursor continuation must reach the second page");
assert_eq!(
second.tools.len(),
1,
"the as_proxy continuation must retain one remaining prefixed tool: {second:?}"
);
assert_ne!(
second.tools[0].name, first_name,
"the continuation must retain the other prefixed tool: {second:?}"
);
assert!(
second.tools[0].name == prefixed || second.tools[0].name == prefixed_fast,
"the continuation must stay on the prefixed as_proxy catalog: {second:?}"
);
drop(client);
paged.shutdown();
let mut unpaged = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-page-size-off", "1.0.0")
.connect_http_with_cx(public_http_target(default_gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the default as_proxy gateway");
let listed = runtime_block_on_bounded(&cx, unpaged.list_tools(&cx, None))
.expect("the default as_proxy catalog must still list prefixed tools");
assert!(
listed.tools.len() >= 2,
"changing only the missing list_page_size must not invent a 1-item page: {listed:?}"
);
drop(unpaged);
default_gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_gateway_list_page_size_pages_prefixed_tools() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_counting_upstream();
let paged = spawn_legacy_as_proxy_http_gateway_configured(
upstream.address(),
None,
false,
PUBLIC_HTTP_TOOL_NAME,
false,
false,
false,
Some(1),
);
let default_gateway = spawn_legacy_as_proxy_http_gateway(upstream.address());
let prefixed = format!("child/{PUBLIC_HTTP_TOOL_NAME}");
let prefixed_fast = format!("child/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let mut client = connect_legacy_http_client(
&cx,
paged.address(),
"e2e-public-http-legacy-as-proxy-page-size",
);
let first = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy list_page_size first",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("gateway list_page_size(1) must page prefixed tools");
assert_eq!(
first.tools.len(),
1,
"as_proxy gateway list_page_size(1) must create a real continuation: {first:?}"
);
let first_name = first.tools[0].name.clone();
assert!(
first_name == prefixed || first_name == prefixed_fast,
"the first as_proxy page must retain a prefixed catalog tool: {first:?}"
);
let cursor = first
.next_cursor
.clone()
.expect("the first as_proxy page must carry an opaque cursor");
let second = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy list_page_size continuation",
client.list_tools(
&cx,
legacy_2024::ListToolsParams {
cursor: Some(cursor),
include_tags: None,
exclude_tags: None,
},
),
)
.expect("the as_proxy cursor continuation must reach the second page");
assert_eq!(
second.tools.len(),
1,
"the as_proxy continuation must retain one remaining prefixed tool: {second:?}"
);
assert_ne!(
second.tools[0].name, first_name,
"the continuation must retain the other prefixed tool: {second:?}"
);
drop(client);
paged.shutdown();
let mut unpaged = connect_legacy_http_client(
&cx,
default_gateway.address(),
"e2e-public-http-legacy-as-proxy-page-size-off",
);
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy default list",
unpaged.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("the default as_proxy catalog must still list prefixed tools");
assert!(
listed.tools.len() >= 2,
"changing only the missing list_page_size must not invent a 1-item page: {listed:?}"
);
drop(unpaged);
default_gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_gateway_list_page_size_pages_prefixed_tools() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_with_options(
false,
Vec::new(),
false,
None,
None,
false,
false,
false,
false,
Some(1),
);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-page-size", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the paged stdio as_proxy gateway");
let first = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("gateway list_page_size(1) must page prefixed stdio tools");
assert_eq!(
first.tools.len(),
1,
"stdio as_proxy list_page_size(1) must create a real continuation: {first:?}"
);
assert!(
first.tools[0].name.starts_with("ext/"),
"the first stdio as_proxy page must retain a prefixed catalog tool: {first:?}"
);
let first_name = first.tools[0].name.clone();
let cursor = first
.next_cursor
.clone()
.expect("the first stdio as_proxy page must carry an opaque cursor");
let second = runtime_block_on_bounded(&cx, client.list_tools(&cx, Some(cursor.as_str())))
.expect("the stdio as_proxy cursor continuation must reach the second page");
assert_eq!(
second.tools.len(),
1,
"the stdio as_proxy continuation must retain one remaining prefixed tool: {second:?}"
);
assert_ne!(
second.tools[0].name, first_name,
"the continuation must retain a different prefixed tool: {second:?}"
);
assert!(
second.tools[0].name.starts_with("ext/"),
"the continuation must stay on the prefixed stdio as_proxy catalog: {second:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_gateway_default_list_is_not_page_size_1() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_configured(false, Vec::new());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-page-size-off", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the default stdio as_proxy gateway");
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("the default stdio as_proxy catalog must still list prefixed tools");
assert!(
listed.tools.len() >= 2,
"changing only the missing list_page_size must not invent a 1-item page: {listed:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_gateway_list_page_size_pages_prefixed_tools() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway_with_options(
Vec::new(),
false,
None,
None,
false,
false,
false,
false,
Some(1),
);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-page-size",
);
let first = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy list_page_size first",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("gateway list_page_size(1) must page prefixed stdio tools");
assert_eq!(
first.tools.len(),
1,
"stdio as_proxy list_page_size(1) must create a real continuation: {first:?}"
);
assert!(
first.tools[0].name.starts_with("ext/"),
"the first stdio as_proxy page must retain a prefixed catalog tool: {first:?}"
);
let first_name = first.tools[0].name.clone();
let cursor = first
.next_cursor
.clone()
.expect("the first stdio as_proxy page must carry an opaque cursor");
let second = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy list_page_size continuation",
client.list_tools(
&cx,
legacy_2024::ListToolsParams {
cursor: Some(cursor),
include_tags: None,
exclude_tags: None,
},
),
)
.expect("the stdio as_proxy cursor continuation must reach the second page");
assert_eq!(
second.tools.len(),
1,
"the stdio as_proxy continuation must retain one remaining prefixed tool: {second:?}"
);
assert_ne!(
second.tools[0].name, first_name,
"the continuation must retain a different prefixed tool: {second:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_gateway_default_list_is_not_page_size_1() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway_with_env(Vec::new());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-page-size-off",
);
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy default list",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("the default stdio as_proxy catalog must still list prefixed tools");
assert!(
listed.tools.len() >= 2,
"changing only the missing list_page_size must not invent a 1-item page: {listed:?}"
);
drop(client);
gateway.shutdown();
}
/// Gateway that registers a local tool under the prefixed as_proxy name
/// before installing the upstream catalog, so `on_duplicate` can keep or
/// replace that collision.
#[cfg(all(feature = "proxy", feature = "tasks"))]
fn spawn_modern_http_as_proxy_duplicate_gateway(
upstream: SocketAddr,
behavior: DuplicateBehavior,
) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("as_proxy on_duplicate gateway HTTP server reactor initializes"),
)
.build()
.expect("as_proxy on_duplicate gateway HTTP server installs an owned runtime");
let outcome = runtime.block_on(async move {
let cx =
Cx::current().expect("owned gateway runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err(
"as_proxy on_duplicate gateway HTTP server control receiver went away"
.to_owned(),
);
}
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream, "/mcp")),
None,
None,
"e2e-http-as-proxy-duplicate-gateway".to_owned(),
"e2e-http-as-proxy-duplicate-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.map_err(|error| format!("as_proxy on_duplicate gateway HTTP plan failed: {error}"))?;
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-live-duplicate-upstream",
"native-h1:e2e-live-duplicate-upstream",
1,
plan,
ClientInfo {
name: "e2e-http-as-proxy-duplicate".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.map_err(|error| {
format!("live HTTP as_proxy on_duplicate upstream connect failed: {error}")
})?;
let catalog = proxy.catalog_typed().map_err(|error| {
format!("live HTTP as_proxy on_duplicate catalog failed: {error}")
})?;
let server = modern::ServerBuilder::new("e2e-http-as-proxy-duplicate", "1.0.0")
.on_duplicate(behavior)
.tool(LocalPrefixedPublicHttpValue {
name: format!("ext/{PUBLIC_HTTP_TOOL_NAME}"),
})
.as_proxy_typed("ext", proxy, catalog)
.map_err(|error| format!("as_proxy_typed on_duplicate install failed: {error}"))?
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message =
format!("as_proxy on_duplicate gateway HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!(
"as_proxy on_duplicate gateway HTTP server address failed: {error}"
);
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err(
"as_proxy on_duplicate gateway HTTP server startup receiver went away"
.to_owned(),
);
}
bound.serve(&cx).await.map_err(|error| {
format!("as_proxy on_duplicate gateway HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("as_proxy on_duplicate gateway HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => {
panic!("as_proxy on_duplicate gateway HTTP server failed to start: {error}")
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("as_proxy on_duplicate gateway HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_gateway_on_duplicate_error_keeps_local_prefixed_tool() {
let cx = Cx::for_request();
let upstream = spawn_modern_as_proxy_counting_upstream();
let gateway =
spawn_modern_http_as_proxy_duplicate_gateway(upstream.address(), DuplicateBehavior::Error);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-duplicate-error", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the Error-on-duplicate as_proxy gateway");
let prefixed = format!("ext/{PUBLIC_HTTP_TOOL_NAME}");
let kept = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.expect("Error must keep the local prefixed tool callable");
assert!(
kept.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "local:alpha",
_ => false,
}),
"as_proxy on_duplicate Error must keep the local prefixed handler: {kept:?}"
);
assert_eq!(
upstream.handler_calls.tool.load(Ordering::SeqCst),
0,
"Error must not forward the colliding prefixed name to the upstream"
);
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.expect_err("changing only the tool name must not reach the unprefixed local or upstream tool");
let _ = missing;
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_gateway_on_duplicate_replace_installs_proxied_tool() {
let cx = Cx::for_request();
let upstream = spawn_modern_as_proxy_counting_upstream();
let gateway = spawn_modern_http_as_proxy_duplicate_gateway(
upstream.address(),
DuplicateBehavior::Replace,
);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-duplicate-replace", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the Replace-on-duplicate as_proxy gateway");
let prefixed = format!("ext/{PUBLIC_HTTP_TOOL_NAME}");
let replaced = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.expect("Replace must install the proxied catalog entry");
assert!(
replaced.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"changing only on_duplicate to Replace must reach the prefixed upstream: {replaced:?}"
);
assert_eq!(
upstream.handler_calls.tool.load(Ordering::SeqCst),
1,
"Replace must forward the colliding prefixed name to the upstream"
);
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.expect_err("changing only the tool name must not reach the unprefixed upstream tool");
let _ = missing;
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
fn spawn_legacy_as_proxy_http_duplicate_gateway(
upstream: SocketAddr,
behavior: DuplicateBehavior,
) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("legacy as_proxy on_duplicate gateway HTTP server reactor initializes"),
)
.build()
.expect("legacy as_proxy on_duplicate gateway HTTP server installs an owned runtime");
let outcome = runtime.block_on(async move {
let cx =
Cx::current().expect("owned gateway runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err(
"legacy as_proxy on_duplicate gateway HTTP server control receiver went away"
.to_owned(),
);
}
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream, "/sse")),
Some(public_http_target(upstream, "/messages")),
"e2e-legacy-http-as-proxy-duplicate-gateway".to_owned(),
"e2e-legacy-http-as-proxy-duplicate-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.map_err(|error| {
format!("legacy as_proxy on_duplicate gateway HTTP plan failed: {error}")
})?;
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-duplicate-upstream",
"native-h1:e2e-legacy-duplicate-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-http-as-proxy-duplicate".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities {
sampling: Some(Default::default()),
elicitation: None,
roots: Some(legacy_2024::RootsCapability {
list_changed: false,
}),
},
cx.clone(),
)
.map_err(|error| {
format!(
"live exact-2024 as_proxy on_duplicate upstream connect failed: {error}"
)
})?;
let catalog = proxy.catalog_typed().map_err(|error| {
format!("live exact-2024 as_proxy on_duplicate catalog failed: {error}")
})?;
let server = ServerBuilder::new("e2e-legacy-http-as-proxy-duplicate", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.on_duplicate(behavior)
.tool(LocalPrefixedPublicHttpValue {
name: format!("child/{PUBLIC_HTTP_TOOL_NAME}"),
})
.as_proxy_typed("child", proxy, catalog)
.map_err(|error| {
format!("legacy as_proxy_typed on_duplicate install failed: {error}")
})?
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!(
"legacy as_proxy on_duplicate gateway HTTP server bind failed: {error}"
);
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!(
"legacy as_proxy on_duplicate gateway HTTP server address failed: {error}"
);
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err(
"legacy as_proxy on_duplicate gateway HTTP server startup receiver went away"
.to_owned(),
);
}
bound.serve(&cx).await.map_err(|error| {
format!(
"legacy as_proxy on_duplicate gateway HTTP server stopped unexpectedly: {error}"
)
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("legacy as_proxy on_duplicate gateway HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => {
panic!("legacy as_proxy on_duplicate gateway HTTP server failed to start: {error}")
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!(
"legacy as_proxy on_duplicate gateway HTTP server readiness channel disconnected"
)
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_gateway_on_duplicate_error_keeps_local_prefixed_tool() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_counting_upstream();
let gateway =
spawn_legacy_as_proxy_http_duplicate_gateway(upstream.address(), DuplicateBehavior::Error);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-duplicate-error",
);
let prefixed = format!("child/{PUBLIC_HTTP_TOOL_NAME}");
let kept = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy on_duplicate Error call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed,
arguments: Some(json!({"value": "alpha"})),
meta: None,
},
),
)
.expect("Error must keep the local prefixed tool callable");
assert_eq!(
legacy_http_tool_text(&kept).as_deref(),
Some("local:alpha"),
"as_proxy on_duplicate Error must keep the local prefixed handler: {kept:?}"
);
assert_eq!(
upstream.handler_calls.tool.load(Ordering::SeqCst),
0,
"Error must not forward the colliding prefixed name to the upstream"
);
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy on_duplicate Error unprefixed",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_TOOL_NAME.to_owned(),
arguments: Some(json!({"value": "alpha"})),
meta: None,
},
),
)
.expect_err("changing only the tool name must not reach the unprefixed local or upstream tool");
let _ = missing;
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_gateway_on_duplicate_replace_installs_proxied_tool() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_counting_upstream();
let gateway = spawn_legacy_as_proxy_http_duplicate_gateway(
upstream.address(),
DuplicateBehavior::Replace,
);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-duplicate-replace",
);
let prefixed = format!("child/{PUBLIC_HTTP_TOOL_NAME}");
let replaced = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy on_duplicate Replace call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed,
arguments: Some(json!({"value": "alpha"})),
meta: None,
},
),
)
.expect("Replace must install the proxied catalog entry");
assert_eq!(
legacy_http_tool_text(&replaced).as_deref(),
Some("tool:alpha"),
"changing only on_duplicate to Replace must reach the prefixed upstream: {replaced:?}"
);
assert_eq!(
upstream.handler_calls.tool.load(Ordering::SeqCst),
1,
"Replace must forward the colliding prefixed name to the upstream"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_gateway_session_state_uses_shared_upstream_bag() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway_with_env(Vec::new());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-state",
);
let mut peer = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-state-peer",
);
let written = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy session state write",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/state_probe".to_owned(),
arguments: Some(json!({"action": "write", "value": "alpha"})),
meta: None,
},
),
)
.expect("exact-2024 stdio as_proxy must write session state on the upstream binding");
assert_eq!(
legacy_http_tool_text(&written).as_deref(),
Some("state:alpha"),
"set_state then get_state on the same as_proxy stdio write must retain the value: {written:?}"
);
let later_read = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy session state later read",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/state_probe".to_owned(),
arguments: Some(json!({"action": "read", "value": ""})),
meta: None,
},
),
)
.expect("a later exact-2024 as_proxy stdio tools/call must still be admitted");
assert_eq!(
legacy_http_tool_text(&later_read).as_deref(),
Some("state:alpha"),
"stdio as_proxy must reuse one upstream session bag: {later_read:?}"
);
let peer_read = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy session state peer read",
peer.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/state_probe".to_owned(),
arguments: Some(json!({"action": "read", "value": ""})),
meta: None,
},
),
)
.expect("a peer inbound SSE session must still be admitted");
assert_eq!(
legacy_http_tool_text(&peer_read).as_deref(),
Some("state:alpha"),
"as_proxy must keep one upstream stdio session bag across inbound clients: {peer_read:?}"
);
drop(client);
drop(peer);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_gateway_session_state_uses_shared_upstream_bag() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_configured(false, Vec::new());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-state", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the stdio as_proxy session-state gateway");
let written = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
"ext/state_probe",
json!({"action": "write", "value": "alpha"}),
),
)
.expect("as_proxy stdio must write session state on the upstream binding");
assert!(
written.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "state:alpha",
_ => false,
}),
"set_state then get_state on the same as_proxy stdio write must retain the value: {written:?}"
);
let later_read = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
"ext/state_probe",
json!({"action": "read", "value": ""}),
),
)
.expect("a later as_proxy stdio POST must still be admitted");
assert!(
later_read.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "state:alpha",
_ => false,
}),
"stdio as_proxy must reuse one upstream session bag across inbound POSTs: {later_read:?}"
);
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "state_probe", json!({"action": "read", "value": ""})),
)
.expect_err("changing only the tool name must not reach the unprefixed state tool");
let _ = missing;
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
fn spawn_modern_http_stdio_as_proxy_duplicate_gateway(
behavior: DuplicateBehavior,
) -> HttpServerFixture {
spawn_modern_http_stdio_as_proxy_gateway_with_duplicate(
false,
Vec::new(),
false,
None,
None,
false,
false,
false,
false,
None,
Some(behavior),
)
.0
}
#[cfg(all(unix, feature = "proxy"))]
fn spawn_legacy_http_stdio_as_proxy_duplicate_gateway(
behavior: DuplicateBehavior,
) -> HttpServerFixture {
spawn_legacy_http_stdio_as_proxy_gateway_with_duplicate(
Vec::new(),
false,
None,
None,
false,
false,
false,
false,
None,
Some(behavior),
)
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_gateway_on_duplicate_error_keeps_local_echo() {
let cx = Cx::for_request();
let gateway = spawn_modern_http_stdio_as_proxy_duplicate_gateway(DuplicateBehavior::Error);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-duplicate-error", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the Error-on-duplicate stdio as_proxy gateway");
let kept = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({"message": "alpha"})),
)
.expect("Error must keep the local prefixed echo callable");
assert!(
kept.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "local:alpha",
_ => false,
}),
"stdio as_proxy on_duplicate Error must keep the local ext/echo handler: {kept:?}"
);
let add = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/add", json!({"a": 2, "b": 3})),
)
.expect("Error must still install the rest of the prefixed stdio catalog");
assert!(
add.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "5",
_ => false,
}),
"a non-colliding prefixed tool must still reach the stdio upstream: {add:?}"
);
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "echo", json!({"message": "alpha"})),
)
.expect_err("changing only the tool name must not reach the unprefixed echo");
let _ = missing;
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_gateway_on_duplicate_replace_installs_echo() {
let cx = Cx::for_request();
let gateway = spawn_modern_http_stdio_as_proxy_duplicate_gateway(DuplicateBehavior::Replace);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-duplicate-replace", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the Replace-on-duplicate stdio as_proxy gateway");
let replaced = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({"message": "alpha"})),
)
.expect("Replace must install the proxied echo");
assert!(
replaced.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "alpha",
_ => false,
}),
"changing only on_duplicate to Replace must reach prefixed ext/echo: {replaced:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_gateway_on_duplicate_error_keeps_local_echo() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_duplicate_gateway(DuplicateBehavior::Error);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-duplicate-error",
);
let kept = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy on_duplicate Error call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/echo".to_owned(),
arguments: Some(json!({"message": "alpha"})),
meta: None,
},
),
)
.expect("Error must keep the local prefixed echo callable");
assert_eq!(
legacy_http_tool_text(&kept).as_deref(),
Some("local:alpha"),
"stdio as_proxy on_duplicate Error must keep the local ext/echo handler: {kept:?}"
);
let add = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy on_duplicate Error peer add",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/add".to_owned(),
arguments: Some(json!({"a": 2, "b": 3})),
meta: None,
},
),
)
.expect("Error must still install the rest of the prefixed stdio catalog");
assert_eq!(
legacy_http_tool_text(&add).as_deref(),
Some("5"),
"a non-colliding prefixed tool must still reach the stdio upstream: {add:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_gateway_on_duplicate_replace_installs_echo() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_duplicate_gateway(DuplicateBehavior::Replace);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-duplicate-replace",
);
let replaced = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy on_duplicate Replace call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/echo".to_owned(),
arguments: Some(json!({"message": "alpha"})),
meta: None,
},
),
)
.expect("Replace must install the proxied echo");
assert_eq!(
legacy_http_tool_text(&replaced).as_deref(),
Some("alpha"),
"changing only on_duplicate to Replace must reach prefixed ext/echo: {replaced:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_gateway_strict_input_refuses_unknown_property() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_with_options(
false,
Vec::new(),
false,
None,
None,
false,
false,
false,
true,
None,
);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-strict-on", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the strict stdio as_proxy gateway");
let admitted = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({"message": "alpha"})),
)
.expect("strict stdio as_proxy must still admit declared arguments");
assert!(
admitted.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "alpha",
_ => false,
}),
"declared arguments must reach prefixed ext/echo under gateway strict validation: {admitted:?}"
);
let refused = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({"message": "alpha", "extra": 1})),
)
.expect("strict stdio as_proxy returns a complete tools/call result, not a transport failure");
assert!(
refused.is_error,
"an unknown property must become a tool-level error when gateway strict validation is on: {refused:?}"
);
assert!(
refused.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text.contains("do not match the declared input schema")
|| text.contains("additional property")
}
_ => false,
}),
"the stdio as_proxy strict refusal must name the input-schema mismatch: {refused:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_gateway_lenient_input_admits_unknown_property() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_with_options(
false,
Vec::new(),
false,
None,
None,
false,
false,
false,
false,
None,
);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-strict-off", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the lenient stdio as_proxy gateway");
let extra = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({"message": "alpha", "extra": 1})),
)
.expect("lenient stdio as_proxy must admit the same extra property");
assert!(
extra.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "alpha",
_ => false,
}),
"changing only the gateway strict flag must admit the extra property: {extra:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_gateway_strict_input_refuses_unknown_property() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway_with_options(
Vec::new(),
false,
None,
None,
false,
false,
false,
true,
None,
);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-strict-on",
);
let admitted = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy strict declared",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/echo".to_owned(),
arguments: Some(json!({"message": "alpha"})),
meta: None,
},
),
)
.expect("strict stdio as_proxy must still admit declared arguments");
assert_eq!(
legacy_http_tool_text(&admitted).as_deref(),
Some("alpha"),
"declared arguments must reach prefixed ext/echo under gateway strict validation: {admitted:?}"
);
let refused = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy strict extra-property",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/echo".to_owned(),
arguments: Some(json!({"message": "alpha", "extra": 1})),
meta: None,
},
),
);
let refused = match refused {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
refused.contains("do not match the declared input schema")
|| refused.contains("additional property")
|| refused.contains("unknown"),
"the exact-2024 stdio as_proxy strict refusal must name the input-schema mismatch: {refused}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_gateway_lenient_input_admits_unknown_property() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway_with_options(
Vec::new(),
false,
None,
None,
false,
false,
false,
false,
None,
);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-strict-off",
);
let extra = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy lenient extra-property",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/echo".to_owned(),
arguments: Some(json!({"message": "alpha", "extra": 1})),
meta: None,
},
),
)
.expect("lenient stdio as_proxy must admit the same extra property");
assert_eq!(
legacy_http_tool_text(&extra).as_deref(),
Some("alpha"),
"changing only the gateway strict flag must admit the extra property: {extra:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_gateway_sliding_window_refuses_second_prefixed_call() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_counting_upstream();
let gateway = spawn_legacy_as_proxy_http_gateway_configured(
upstream.address(),
None,
false,
PUBLIC_HTTP_TOOL_NAME,
true,
false,
false,
None,
);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-sliding",
);
let prefixed = format!("child/{PUBLIC_HTTP_TOOL_NAME}");
let first = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy sliding first",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed.clone(),
arguments: Some(json!({"value": "alpha"})),
meta: None,
},
),
)
.expect("the first prefixed tools/call must be admitted by the gateway sliding window");
assert_eq!(
legacy_http_tool_text(&first).as_deref(),
Some("tool:alpha"),
"the first exact-2024 as_proxy sliding-window tools/call must reach the upstream: {first:?}"
);
let limited = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy sliding second",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed.clone(),
arguments: Some(json!({"value": "alpha"})),
meta: None,
},
),
);
let limited = match limited {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
limited.contains("Rate limit exceeded"),
"the refused second exact-2024 as_proxy tools/call must keep the sliding-window error: {limited}"
);
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy sliding tools/list",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("changing only the method must still be admitted by the gateway sliding window");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed),
"tools/list must stay callable after prefixed tools/call is sliding-window limited: {listed:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_gateway_rate_limit_refuses_second_prefixed_call() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_counting_upstream();
let gateway = spawn_legacy_as_proxy_http_gateway_configured(
upstream.address(),
None,
true,
PUBLIC_HTTP_TOOL_NAME,
false,
false,
false,
None,
);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-rate-limit",
);
let prefixed = format!("child/{PUBLIC_HTTP_TOOL_NAME}");
let first = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy rate-limit first",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed.clone(),
arguments: Some(json!({"value": "alpha"})),
meta: None,
},
),
)
.expect("the first prefixed tools/call must be admitted by the gateway rate limiter");
assert_eq!(
legacy_http_tool_text(&first).as_deref(),
Some("tool:alpha"),
"the first exact-2024 as_proxy rate-limited tools/call must reach the upstream: {first:?}"
);
let limited = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy rate-limit second",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed.clone(),
arguments: Some(json!({"value": "alpha"})),
meta: None,
},
),
);
let limited = match limited {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
limited.contains("Rate limit exceeded"),
"the refused second exact-2024 as_proxy tools/call must keep the rate-limit error: {limited}"
);
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy rate-limit tools/list",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("changing only the method must still be admitted by the gateway rate limiter");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed),
"tools/list must stay callable after prefixed tools/call is rate-limited: {listed:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_forwards_transformed_prefixed_tool() {
let cx = Cx::for_request();
let upstream = spawn_modern_as_proxy_transform_upstream();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-transform", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy transform gateway");
let prefixed = format!("ext/{PUBLIC_HTTP_TRANSFORMED_TOOL_NAME}");
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("as_proxy must list the prefixed transformed tool");
let transformed = listed
.tools
.iter()
.find(|tool| tool.name == prefixed)
.unwrap_or_else(|| {
panic!("the as_proxy catalog must advertise the prefixed transformed name: {listed:?}")
});
assert!(
listed
.tools
.iter()
.all(|tool| tool.name != PUBLIC_HTTP_TRANSFORMED_TOOL_NAME
&& tool.name != PUBLIC_HTTP_TOOL_NAME
&& tool.name != format!("ext/{PUBLIC_HTTP_TOOL_NAME}")),
"as_proxy must not keep the unprefixed transformed name or the parent tool: {listed:?}"
);
let schema = serde_json::to_string(&transformed.input_schema)
.expect("the prefixed transformed input schema serializes");
assert!(
schema.contains("query"),
"as_proxy must retain the renamed argument on the prefixed catalog: {schema}"
);
assert!(
!schema.contains("\"value\""),
"as_proxy must drop the original argument name from the prefixed catalog: {schema}"
);
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_TRANSFORMED_TOOL_NAME,
json!({"query": "alpha"}),
),
)
.expect_err("changing only the tool name must not reach the unprefixed transformed tool");
let _ = missing;
let looked_up = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, &prefixed, json!({"query": "alpha"})),
)
.expect("as_proxy must forward the renamed argument onto the live transformed upstream");
assert!(
looked_up.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the rewritten argument must reach the parent handler through as_proxy: {looked_up:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_forwards_transformed_prefixed_tool() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_transform_upstream();
let gateway = spawn_legacy_as_proxy_http_gateway_configured(
upstream.address(),
None,
false,
PUBLIC_HTTP_TRANSFORMED_TOOL_NAME,
false,
false,
false,
None,
);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-transform",
);
let prefixed = format!("child/{PUBLIC_HTTP_TRANSFORMED_TOOL_NAME}");
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy transform tools/list",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("exact-2024 as_proxy must list the prefixed transformed tool");
let transformed = listed
.tools
.iter()
.find(|tool| tool.name == prefixed)
.unwrap_or_else(|| {
panic!(
"the exact-2024 as_proxy catalog must advertise the prefixed transformed name: {listed:?}"
)
});
assert!(
listed
.tools
.iter()
.all(|tool| tool.name != PUBLIC_HTTP_TRANSFORMED_TOOL_NAME
&& tool.name != PUBLIC_HTTP_TOOL_NAME),
"exact-2024 as_proxy must not keep the unprefixed transformed name or the parent tool: {listed:?}"
);
let schema = serde_json::to_string(&transformed.input_schema)
.expect("the prefixed transformed input schema serializes");
assert!(
schema.contains("query"),
"exact-2024 as_proxy must retain the renamed argument on the prefixed catalog: {schema}"
);
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy transform unprefixed",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_TRANSFORMED_TOOL_NAME.to_owned(),
arguments: Some(json!({"query": "alpha"})),
meta: None,
},
),
);
assert!(
missing.is_err(),
"changing only the missing prefix must not reach the unprefixed transformed tool: {missing:?}"
);
let looked_up = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy transform tools/call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed,
arguments: Some(json!({"query": "alpha"})),
meta: None,
},
),
)
.expect(
"exact-2024 as_proxy must forward the renamed argument onto the live transformed upstream",
);
assert_eq!(
legacy_http_tool_text(&looked_up).as_deref(),
Some("tool:alpha"),
"the rewritten argument must reach the parent handler through exact-2024 as_proxy: {looked_up:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_gateway_cache_hits_prefixed_allowlisted_tool() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_with_options(
false,
Vec::new(),
false,
None,
Some(vec!["ext/cache_probe".to_owned()]),
false,
false,
false,
false,
None,
);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-cache", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy cache gateway");
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "cache_probe", json!({"token": "alpha"})),
)
.expect_err("changing only the tool name must not reach the unprefixed stdio cache_probe");
let _ = missing;
let first = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/cache_probe", json!({"token": "alpha"})),
)
.expect("the first allowlisted prefixed cache_probe must miss and reach the stdio upstream");
assert!(
first.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "alpha:0",
_ => false,
}),
"the first stdio as_proxy cache miss must increment the process-local counter: {first:?}"
);
let cached = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/cache_probe", json!({"token": "alpha"})),
)
.expect("the second identical prefixed cache_probe must be a gateway cache hit");
assert!(
cached.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "alpha:0",
_ => false,
}),
"the stdio as_proxy cache hit must keep the first complete result: {cached:?}"
);
let missed = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/cache_probe", json!({"token": "beta"})),
)
.expect("changing only the arguments must miss the stdio as_proxy gateway cache");
assert!(
missed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "beta:1",
_ => false,
}),
"a stdio as_proxy cache miss must increment the process-local counter: {missed:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_gateway_cache_hits_prefixed_allowlisted_tool() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway_with_options(
Vec::new(),
false,
None,
Some(vec!["ext/cache_probe".to_owned()]),
false,
false,
false,
false,
None,
);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-cache",
);
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy cache unprefixed",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "cache_probe".to_owned(),
arguments: Some(json!({"token": "alpha"})),
meta: None,
},
),
);
assert!(
missing.is_err(),
"changing only the missing prefix must not reach the unprefixed stdio cache_probe: {missing:?}"
);
let first = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy cache miss",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/cache_probe".to_owned(),
arguments: Some(json!({"token": "alpha"})),
meta: None,
},
),
)
.expect("the first allowlisted prefixed cache_probe must miss and reach the stdio upstream");
assert_eq!(
legacy_http_tool_text(&first).as_deref(),
Some("alpha:0"),
"the first exact-2024 stdio as_proxy cache miss must increment: {first:?}"
);
let cached = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy cache hit",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/cache_probe".to_owned(),
arguments: Some(json!({"token": "alpha"})),
meta: None,
},
),
)
.expect("the second identical prefixed cache_probe must be a gateway cache hit");
assert_eq!(
legacy_http_tool_text(&cached).as_deref(),
Some("alpha:0"),
"the exact-2024 stdio as_proxy cache hit must keep the first complete result: {cached:?}"
);
let missed = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy cache arg miss",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/cache_probe".to_owned(),
arguments: Some(json!({"token": "beta"})),
meta: None,
},
),
)
.expect("changing only the arguments must miss the stdio as_proxy gateway cache");
assert_eq!(
legacy_http_tool_text(&missed).as_deref(),
Some("beta:1"),
"a exact-2024 stdio as_proxy cache miss must increment: {missed:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_gateway_rate_limit_refuses_second_prefixed_call() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_with_options(
false,
Vec::new(),
false,
None,
None,
true,
false,
false,
false,
None,
);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-rate-limit", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy rate-limit gateway");
let first = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({"message": "alpha"})),
)
.expect("the first prefixed stdio tools/call must be admitted by the gateway rate limiter");
assert!(
first.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "alpha",
_ => false,
}),
"the first stdio as_proxy rate-limited tools/call must reach the echo process: {first:?}"
);
let limited = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({"message": "alpha"})),
)
.expect_err("a second prefixed stdio tools/call must be refused by the gateway rate limiter");
let limited = format!("{limited:?}");
assert!(
limited.contains("Rate limit exceeded"),
"the refused second stdio as_proxy tools/call must keep the rate-limit error: {limited}"
);
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("changing only the method must still be admitted by the gateway rate limiter");
assert!(
listed.tools.iter().any(|tool| tool.name == "ext/echo"),
"tools/list must stay callable after prefixed stdio tools/call is rate-limited: {listed:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_gateway_rate_limit_refuses_second_prefixed_call() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway_with_options(
Vec::new(),
false,
None,
None,
true,
false,
false,
false,
None,
);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-rate-limit",
);
let first = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy rate-limit first",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/echo".to_owned(),
arguments: Some(json!({"message": "alpha"})),
meta: None,
},
),
)
.expect("the first prefixed stdio tools/call must be admitted by the gateway rate limiter");
assert_eq!(
legacy_http_tool_text(&first).as_deref(),
Some("alpha"),
"the first exact-2024 stdio as_proxy rate-limited tools/call must reach the echo process: {first:?}"
);
let limited = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy rate-limit second",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/echo".to_owned(),
arguments: Some(json!({"message": "alpha"})),
meta: None,
},
),
);
let limited = match limited {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
limited.contains("Rate limit exceeded"),
"the refused second exact-2024 stdio as_proxy tools/call must keep the rate-limit error: {limited}"
);
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy rate-limit tools/list",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("changing only the method must still be admitted by the gateway rate limiter");
assert!(
listed.tools.iter().any(|tool| tool.name == "ext/echo"),
"tools/list must stay callable after prefixed stdio tools/call is rate-limited: {listed:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_gateway_sliding_window_refuses_second_prefixed_call() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_with_options(
false,
Vec::new(),
false,
None,
None,
false,
true,
false,
false,
None,
);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-sliding", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy sliding-window gateway");
let first = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({"message": "alpha"})),
)
.expect("the first prefixed stdio tools/call must be admitted by the gateway sliding window");
assert!(
first.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "alpha",
_ => false,
}),
"the first stdio as_proxy sliding-window tools/call must reach the echo process: {first:?}"
);
let limited = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({"message": "alpha"})),
)
.expect_err("a second prefixed stdio tools/call must be refused by the gateway sliding window");
let limited = format!("{limited:?}");
assert!(
limited.contains("Rate limit exceeded"),
"the refused second stdio as_proxy tools/call must keep the sliding-window error: {limited}"
);
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("changing only the method must still be admitted by the gateway sliding window");
assert!(
listed.tools.iter().any(|tool| tool.name == "ext/echo"),
"tools/list must stay callable after prefixed stdio tools/call is sliding-window limited: {listed:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_gateway_sliding_window_refuses_second_prefixed_call() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway_with_options(
Vec::new(),
false,
None,
None,
false,
true,
false,
false,
None,
);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-sliding",
);
let first = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy sliding first",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/echo".to_owned(),
arguments: Some(json!({"message": "alpha"})),
meta: None,
},
),
)
.expect("the first prefixed stdio tools/call must be admitted by the gateway sliding window");
assert_eq!(
legacy_http_tool_text(&first).as_deref(),
Some("alpha"),
"the first exact-2024 stdio as_proxy sliding-window tools/call must reach the echo process: {first:?}"
);
let limited = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy sliding second",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/echo".to_owned(),
arguments: Some(json!({"message": "alpha"})),
meta: None,
},
),
);
let limited = match limited {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
limited.contains("Rate limit exceeded"),
"the refused second exact-2024 stdio as_proxy tools/call must keep the sliding-window error: {limited}"
);
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy sliding tools/list",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("changing only the method must still be admitted by the gateway sliding window");
assert!(
listed.tools.iter().any(|tool| tool.name == "ext/echo"),
"tools/list must stay callable after prefixed stdio tools/call is sliding-window limited: {listed:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_gateway_static_token_refuses_missing_and_wrong() {
const MAX_NATIVE_HTTP_RESPONSE_BYTES: usize = 1 << 20;
fn exchange(address: SocketAddr, authorization: Option<&str>, body: &[u8]) -> Vec<u8> {
let mut stream = std::net::TcpStream::connect_timeout(&address, HTTP_OPERATION_BOUND)
.expect("native HTTP client connects to the stdio as_proxy auth gateway");
stream
.set_read_timeout(Some(HTTP_OPERATION_BOUND))
.expect("native HTTP client read deadline is configured");
stream
.set_write_timeout(Some(HTTP_OPERATION_BOUND))
.expect("native HTTP client write deadline is configured");
let authorization_header =
authorization.map_or_else(String::new, |value| format!("Authorization: {value}\r\n"));
let request = format!(
"POST /mcp HTTP/1.1\r\nHost: {address}\r\n{authorization_header}Accept: application/json\r\nContent-Type: application/json\r\nMCP-Protocol-Version: {}\r\nMcp-Method: tools/call\r\nMcp-Name: ext/echo\r\nContent-Length: {}\r\n\r\n",
modern::PROTOCOL_VERSION,
body.len(),
);
stream
.write_all(request.as_bytes())
.and_then(|()| stream.write_all(body))
.expect("native HTTP request commits to the stdio as_proxy auth gateway");
let mut response = Vec::new();
let mut buffer = [0_u8; 8 * 1024];
loop {
let read = stream
.read(&mut buffer)
.expect("native HTTP response reads within its configured deadline");
if read == 0 {
break;
}
assert!(
response
.len()
.checked_add(read)
.is_some_and(|size| size <= MAX_NATIVE_HTTP_RESPONSE_BYTES),
"native HTTP response exceeds the test's bounded response budget"
);
response.extend_from_slice(&buffer[..read]);
}
response
}
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_with_options(
false,
Vec::new(),
false,
None,
None,
false,
false,
true,
false,
None,
);
let tool = JsonRpcRequest::new(
"tools/call",
Some(json!({
"name": "ext/echo",
"arguments": {"message": "alpha"},
"_meta": {
"io.modelcontextprotocol/protocolVersion": modern::PROTOCOL_VERSION,
"io.modelcontextprotocol/clientCapabilities": {
"extensions": {
"io.modelcontextprotocol/tasks": {}
}
},
},
})),
2_i64,
);
let tool_body = serde_json::to_vec(&tool).expect("stdio as_proxy auth tool request serializes");
let missing = exchange(gateway.address(), None, &tool_body);
assert!(
missing.starts_with(b"HTTP/1.1 401"),
"missing Authorization must be refused before the stdio as_proxy gateway dispatches: {}",
String::from_utf8_lossy(&missing)
);
let wrong = exchange(gateway.address(), Some("Bearer beta"), &tool_body);
assert!(
wrong.starts_with(b"HTTP/1.1 401"),
"changing only the bearer token must be refused before stdio as_proxy dispatch: {}",
String::from_utf8_lossy(&wrong)
);
let admitted = exchange(gateway.address(), Some("Bearer alpha"), &tool_body);
assert!(
admitted.starts_with(b"HTTP/1.1 200"),
"the matching bearer token must admit prefixed stdio tools/call: {}",
String::from_utf8_lossy(&admitted)
);
let body = String::from_utf8_lossy(&admitted);
assert!(
body.contains("alpha"),
"the matching stdio as_proxy bearer token must reach ext/echo: {body}"
);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_gateway_static_token_refuses_missing_and_wrong() {
let gateway = spawn_legacy_http_stdio_as_proxy_gateway_with_options(
Vec::new(),
false,
None,
None,
false,
false,
true,
false,
None,
);
let (_missing_stream, missing) =
write_legacy_native_http(gateway.address(), "GET", "/sse", None, &[], false);
assert_legacy_bearer_challenge(&missing, "missing Authorization on stdio as_proxy GET /sse");
let (_wrong_stream, wrong) = write_legacy_native_http(
gateway.address(),
"GET",
"/sse",
Some("Bearer beta"),
&[],
false,
);
assert_legacy_bearer_challenge(&wrong, "wrong bearer on stdio as_proxy GET /sse");
let (mut sse, opened, messages) = initialize_legacy_native_session(
gateway.address(),
Some("Bearer alpha"),
"e2e-public-http-legacy-stdio-as-proxy-auth",
);
let tool = JsonRpcRequest::new(
"tools/call",
Some(json!({
"name": "ext/echo",
"arguments": {"message": "alpha"},
})),
2_i64,
);
let tool_body =
serde_json::to_vec(&tool).expect("exact-2024 stdio as_proxy tools/call serializes");
let (_missing_post, missing_post) = write_legacy_native_http(
gateway.address(),
"POST",
&messages,
None,
&tool_body,
false,
);
assert_legacy_bearer_challenge(
&missing_post,
"missing Authorization on stdio as_proxy POST /messages",
);
let (_wrong_post, wrong_post) = write_legacy_native_http(
gateway.address(),
"POST",
&messages,
Some("Bearer beta"),
&tool_body,
false,
);
assert_legacy_bearer_challenge(&wrong_post, "wrong bearer on stdio as_proxy POST /messages");
let (_tool_stream, tool_response) = write_legacy_native_http(
gateway.address(),
"POST",
&messages,
Some("Bearer alpha"),
&tool_body,
false,
);
assert!(
tool_response.starts_with(b"HTTP/1.1 202") || tool_response.starts_with(b"HTTP/1.1 200"),
"the matching bearer token must admit prefixed stdio as_proxy tools/call: {}",
String::from_utf8_lossy(&tool_response)
);
let mut sse_body = opened;
let retained = read_legacy_sse_until(&mut sse, &mut sse_body, "alpha");
assert!(
retained.contains("alpha"),
"the matching exact-2024 stdio as_proxy bearer token must reach ext/echo: {retained}"
);
drop(sse);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_forwards_transformed_prefixed_tool() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_configured(
false,
vec![("FASTMCP_TRANSFORM_ECHO".to_owned(), "1".to_owned())],
);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-transform", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy transform gateway");
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "echo_text", json!({"text": "alpha"})),
)
.expect_err("changing only the tool name must not reach the unprefixed transformed echo");
let _ = missing;
let looked_up = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo_text", json!({"text": "alpha"})),
)
.expect("as_proxy must forward the renamed argument onto the live stdio transformed echo");
assert!(
looked_up.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "alpha",
_ => false,
}),
"the rewritten argument must reach the parent echo through as_proxy: {looked_up:?}"
);
let parent = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({"message": "alpha"})),
)
.expect("the prefixed parent echo must stay registered beside the transform");
assert!(
parent.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "alpha",
_ => false,
}),
"the prefixed parent echo must keep the original argument name: {parent:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_forwards_transformed_prefixed_tool() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway_with_env(vec![(
"FASTMCP_TRANSFORM_ECHO".to_owned(),
"1".to_owned(),
)]);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-transform",
);
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy transform unprefixed",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "echo_text".to_owned(),
arguments: Some(json!({"text": "alpha"})),
meta: None,
},
),
);
assert!(
missing.is_err(),
"changing only the missing prefix must not reach the unprefixed transformed echo: {missing:?}"
);
let looked_up = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy transform tools/call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/echo_text".to_owned(),
arguments: Some(json!({"text": "alpha"})),
meta: None,
},
),
)
.expect("exact-2024 as_proxy must forward the renamed argument onto the live stdio transformed echo");
assert_eq!(
legacy_http_tool_text(&looked_up).as_deref(),
Some("alpha"),
"the rewritten argument must reach the parent echo through exact-2024 as_proxy: {looked_up:?}"
);
let parent = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy transform parent echo",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/echo".to_owned(),
arguments: Some(json!({"message": "alpha"})),
meta: None,
},
),
)
.expect("the prefixed parent echo must stay registered beside the transform");
assert_eq!(
legacy_http_tool_text(&parent).as_deref(),
Some("alpha"),
"the prefixed parent echo must keep the original argument name: {parent:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_resource_composes_nested_prompt() {
let cx = Cx::for_request();
let upstream = spawn_modern_compose_prompt_http_server();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-resource-compose-prompt", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy resource-compose-prompt gateway");
let missing = runtime_block_on_bounded(
&cx,
client.read_resource(
&cx,
&format!("ext/{PUBLIC_HTTP_PROMPT_COMPOSE_RESOURCE_URI}"),
),
)
.expect_err("modern as_proxy must keep the compose-prompt resource URI unprefixed");
let _ = missing;
let composed = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_PROMPT_COMPOSE_RESOURCE_URI),
)
.expect("as_proxy must forward unprefixed compose-prompt resources/read onto the live HTTP upstream");
assert!(
composed.contents.iter().any(|content| match content {
EmbeddedResourceContents::Text { text, .. } => text == "compose-prompt:prompt:alpha",
_ => false,
}),
"as_proxy resources/read must retain the nested prompt text: {composed:?}"
);
let missing_prompt = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_PROMPT_COMPOSE_MISSING_RESOURCE_URI),
);
let missing_prompt = match missing_prompt {
Ok(result) => panic!(
"changing only the nested prompt name must stay a handler-visible refusal: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("public-http-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy resource compose-prompt must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_resource_composes_nested_prompt() {
let cx = Cx::for_request();
let upstream = spawn_legacy_compose_prompt_http_server();
let gateway = spawn_legacy_as_proxy_instructions_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-resource-compose-prompt",
);
let prefixed = format!("child/{PUBLIC_HTTP_PROMPT_COMPOSE_RESOURCE_URI}");
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy resource compose-prompt unprefixed",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_PROMPT_COMPOSE_RESOURCE_URI.to_owned(),
meta: None,
},
),
)
.expect_err(
"changing only the missing prefix must not reach the unprefixed compose-prompt resource",
);
let _ = missing;
let composed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy resource compose-prompt",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: prefixed,
meta: None,
},
),
)
.expect("as_proxy must forward prefixed compose-prompt resources/read onto the live exact-2024 HTTP upstream");
let composed =
serde_json::to_value(composed).expect("the exact-2024 HTTP resource result serializes");
assert_eq!(
composed["contents"][0]["text"],
json!("compose-prompt:prompt:alpha"),
"as_proxy resources/read must retain the nested prompt text: {composed}"
);
let missing_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy resource compose-prompt missing nested prompt",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: format!("child/{PUBLIC_HTTP_PROMPT_COMPOSE_MISSING_RESOURCE_URI}"),
meta: None,
},
),
);
let missing_prompt = match missing_prompt {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("public-http-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy resource compose-prompt must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_request_timeout_of_hold_tool_without_handler_timeout() {
let cx = Cx::for_request();
let upstream = spawn_modern_as_proxy_hold_upstream();
let gateway = spawn_modern_http_identity_proxy_gateway_configured(
upstream.address(),
None,
false,
Some(1),
None,
false,
false,
false,
false,
None,
);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-request-timeout", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy request_timeout gateway");
let prefixed_hold = format!("ext/{PUBLIC_HTTP_HOLD_TOOL_NAME}");
let prefixed_fast = format!("ext/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_HOLD_TOOL_NAME, json!({})),
)
.expect_err("changing only the tool name must not reach the unprefixed hold handler");
let _ = missing;
let timed_out = runtime_block_on_bounded(&cx, client.call_tool(&cx, &prefixed_hold, json!({})));
let timed_out = match timed_out {
Ok(result) => {
assert!(
result.is_error,
"a hold tool without timeout() must stay an error under gateway request_timeout: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
timed_out.contains("Request timeout exceeded")
|| timed_out.contains("RequestCancelled")
|| timed_out.contains("Request cancelled"),
"gateway request_timeout(1) must refuse prefixed hold before it returns held: {timed_out}"
);
assert!(
!timed_out.contains("held"),
"changing only the missing handler timeout must not let hold complete: {timed_out}"
);
let peer = runtime_block_on_bounded(&cx, client.call_tool(&cx, &prefixed_fast, json!({})))
.expect("changing only the tool name must still reach the undisabled fast handler");
assert!(
peer.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"as_proxy must keep the prefixed fast peer callable after a request_timeout: {peer:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_request_timeout_of_hold_tool_without_handler_timeout() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_hold_upstream();
let gateway =
spawn_legacy_as_proxy_instructions_gateway_named(upstream.address(), None, false, Some(1));
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-request-timeout",
);
let prefixed_hold = format!("child/{PUBLIC_HTTP_HOLD_TOOL_NAME}");
let prefixed_fast = format!("child/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let missing = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_HOLD_TOOL_NAME,
json!({}),
"exact-2024 HTTP as_proxy request_timeout unprefixed",
)
.expect_err("changing only the tool name must not reach the unprefixed hold handler");
let _ = missing;
let timed_out = legacy_http_call(
&cx,
&mut client,
&prefixed_hold,
json!({}),
"exact-2024 HTTP as_proxy request_timeout hold",
);
let timed_out = match timed_out {
Ok(result) => {
assert!(
result.is_error,
"a hold tool without timeout() must stay an error under gateway request_timeout: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
timed_out.contains("Request timeout exceeded")
|| timed_out.contains("RequestCancelled")
|| timed_out.contains("Request cancelled"),
"gateway request_timeout(1) must refuse prefixed hold before it returns held: {timed_out}"
);
assert!(
!timed_out.contains("held"),
"changing only the missing handler timeout must not let hold complete: {timed_out}"
);
let peer = legacy_http_call(
&cx,
&mut client,
&prefixed_fast,
json!({}),
"exact-2024 HTTP as_proxy request_timeout peer",
)
.expect("changing only the tool name must still reach the undisabled fast handler");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("fast"),
"as_proxy must keep the prefixed fast peer callable after a request_timeout: {peer:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_request_timeout_of_hold_tool_without_handler_timeout() {
let cx = Cx::for_request();
let (gateway, _) = spawn_modern_http_stdio_as_proxy_gateway_with_options(
false,
Vec::new(),
false,
Some(1),
None,
false,
false,
false,
false,
None,
);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-request-timeout", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy request_timeout gateway");
let missing = runtime_block_on_bounded(&cx, client.call_tool(&cx, "hold_echo", json!({})))
.expect_err("changing only the tool name must not reach the unprefixed stdio hold handler");
let _ = missing;
let timed_out =
runtime_block_on_bounded(&cx, client.call_tool(&cx, "ext/hold_echo", json!({})));
let timed_out = match timed_out {
Ok(result) => {
assert!(
result.is_error,
"a hold tool without timeout() must stay an error under gateway request_timeout: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
timed_out.contains("Request timeout exceeded")
|| timed_out.contains("RequestCancelled")
|| timed_out.contains("Request cancelled"),
"gateway request_timeout(1) must refuse prefixed hold_echo before it returns held: {timed_out}"
);
assert!(
!timed_out.contains("held"),
"changing only the missing handler timeout must not let hold_echo complete: {timed_out}"
);
let peer = runtime_block_on_bounded(&cx, client.call_tool(&cx, "ext/fast_echo", json!({})))
.expect("changing only the tool name must still reach the undisabled stdio fast handler");
assert!(
peer.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"as_proxy must keep prefixed fast_echo callable after a request_timeout: {peer:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_request_timeout_of_hold_tool_without_handler_timeout() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway_with_options(
Vec::new(),
false,
Some(1),
None,
false,
false,
false,
false,
None,
);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-request-timeout",
);
let missing = legacy_http_call(
&cx,
&mut client,
"hold_echo",
json!({}),
"exact-2024 stdio as_proxy request_timeout unprefixed",
)
.expect_err("changing only the tool name must not reach the unprefixed stdio hold handler");
let _ = missing;
let timed_out = legacy_http_call(
&cx,
&mut client,
"ext/hold_echo",
json!({}),
"exact-2024 stdio as_proxy request_timeout hold",
);
let timed_out = match timed_out {
Ok(result) => {
assert!(
result.is_error,
"a hold tool without timeout() must stay an error under gateway request_timeout: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
timed_out.contains("Request timeout exceeded")
|| timed_out.contains("RequestCancelled")
|| timed_out.contains("Request cancelled"),
"gateway request_timeout(1) must refuse prefixed hold_echo before it returns held: {timed_out}"
);
assert!(
!timed_out.contains("held"),
"changing only the missing handler timeout must not let hold_echo complete: {timed_out}"
);
let peer = legacy_http_call(
&cx,
&mut client,
"ext/fast_echo",
json!({}),
"exact-2024 stdio as_proxy request_timeout peer",
)
.expect("changing only the tool name must still reach the undisabled stdio fast handler");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("fast"),
"as_proxy must keep prefixed fast_echo callable after a request_timeout: {peer:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_forwards_inbound_log_level() {
let cx = Cx::for_request();
let upstream = spawn_modern_log_http_server();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let prefixed = format!("ext/{PUBLIC_HTTP_LOG_TOOL_NAME}");
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-log", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy log gateway");
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("the live HTTP as_proxy gateway must advertise the prefixed log tool");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed),
"as_proxy_typed must prefix the live log tool: {listed:?}"
);
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_LOG_TOOL_NAME, json!({})),
)
.expect_err("changing only the tool name must not reach the unprefixed log handler");
let _ = missing;
runtime_block_on_bounded(&cx, client.call_tool(&cx, &prefixed, json!({})))
.expect("as_proxy must still forward the prefixed log tool before set_log_level");
assert!(
!client
.take_server_notifications()
.iter()
.any(|notification| {
matches!(
notification,
modern::ServerNotification::Message(message)
if message.data == json!(PUBLIC_HTTP_HANDLER_LOG_TEXT)
)
}),
"omitting only set_log_level must keep as_proxy ctx.info silent"
);
client
.set_log_level(modern::LoggingLevel::Info)
.expect("info logLevel is stored as request metadata");
runtime_block_on_bounded(&cx, client.call_tool(&cx, &prefixed, json!({})))
.expect("as_proxy must forward the prefixed log tool after set_log_level(Info)");
let info_notifications = client.take_server_notifications();
assert!(
info_notifications.iter().any(|notification| matches!(
notification,
modern::ServerNotification::Message(message)
if message.level == modern::LoggingLevel::Info
&& message.data == json!(PUBLIC_HTTP_HANDLER_LOG_TEXT)
)),
"as_proxy must retain upstream ctx.info after inbound set_log_level(Info): {info_notifications:?}"
);
client
.set_log_level(modern::LoggingLevel::Emergency)
.expect("emergency logLevel still stores request metadata locally");
runtime_block_on_bounded(&cx, client.call_tool(&cx, &prefixed, json!({})))
.expect("raising only the logLevel floor cannot break the same as_proxy tools/call");
let emergency_notifications = client.take_server_notifications();
assert!(
!emergency_notifications.iter().any(|notification| matches!(
notification,
modern::ServerNotification::Message(message)
if message.level == modern::LoggingLevel::Info
&& message.data == json!(PUBLIC_HTTP_HANDLER_LOG_TEXT)
)),
"raising only the inbound logLevel floor must suppress as_proxy ctx.info: {emergency_notifications:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_forwards_inbound_log_level() {
let cx = Cx::for_request();
let gateway = spawn_modern_http_stdio_as_proxy_gateway_without_precreated_task();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-log", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy log gateway");
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("the live stdio as_proxy gateway must advertise the prefixed echo tool");
assert!(
listed.tools.iter().any(|tool| tool.name == "ext/echo"),
"as_proxy must prefix the shipped echo tool: {listed:?}"
);
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "echo", json!({"message": "log"})),
)
.expect_err("changing only the tool name must not reach the unprefixed echo handler");
let _ = missing;
runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({"message": "log"})),
)
.expect("as_proxy must still forward ext/echo before set_log_level");
assert!(
!client
.take_server_notifications()
.iter()
.any(|notification| {
matches!(
notification,
modern::ServerNotification::Message(message)
if message.data == json!("echo-handler-info")
)
}),
"omitting only set_log_level must keep stdio as_proxy ctx.info silent"
);
client
.set_log_level(modern::LoggingLevel::Info)
.expect("info logLevel is stored as request metadata");
runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({"message": "log"})),
)
.expect("as_proxy must forward ext/echo after set_log_level(Info)");
let info_notifications = client.take_server_notifications();
assert!(
info_notifications.iter().any(|notification| matches!(
notification,
modern::ServerNotification::Message(message)
if message.level == modern::LoggingLevel::Info
&& message.data == json!("echo-handler-info")
)),
"stdio as_proxy must retain upstream ctx.info after inbound set_log_level(Info): {info_notifications:?}"
);
client
.set_log_level(modern::LoggingLevel::Emergency)
.expect("emergency logLevel still stores request metadata locally");
runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({"message": "log"})),
)
.expect("raising only the logLevel floor cannot break the same stdio as_proxy tools/call");
let emergency_notifications = client.take_server_notifications();
assert!(
!emergency_notifications.iter().any(|notification| matches!(
notification,
modern::ServerNotification::Message(message)
if message.level == modern::LoggingLevel::Info
&& message.data == json!("echo-handler-info")
)),
"raising only the inbound logLevel floor must suppress stdio as_proxy ctx.info: {emergency_notifications:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_forwards_inbound_sampling_capability() {
let cx = Cx::for_request();
let upstream = spawn_modern_sampling_http_server();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let prefixed_sample = format!("ext/{PUBLIC_HTTP_SAMPLING_TOOL_NAME}");
let prefixed_roots = format!("ext/{PUBLIC_HTTP_ROOTS_TOOL_NAME}");
let mut capabilities = ClientCapabilities::default();
capabilities.sampling = Some(Default::default());
capabilities.roots = serde_json::from_value(json!({})).expect("roots capability is valid");
capabilities.elicitation = serde_json::from_value(json!({"form": {}, "url": {}}))
.expect("form and url elicitation capabilities are valid");
let mut admitted = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-sampling", "1.0.0")
.capabilities(capabilities)
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the sampling public facade connects to the live HTTP as_proxy gateway");
let listed = runtime_block_on_bounded(&cx, admitted.list_tools(&cx, None))
.expect("the live HTTP as_proxy gateway must advertise the prefixed sampling tool");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed_sample),
"as_proxy_typed must prefix the live sampling tool: {listed:?}"
);
let missing = runtime_block_on_bounded(
&cx,
admitted.call_tool_result(&cx, PUBLIC_HTTP_SAMPLING_TOOL_NAME, json!({})),
)
.expect_err("changing only the tool name must not reach the unprefixed sampling handler");
let _ = missing;
let sampled = runtime_block_on_bounded(
&cx,
admitted.call_tool_result(&cx, &prefixed_sample, json!({})),
)
.expect("as_proxy must forward inbound sampling so upstream ctx.final_sampling is admitted");
let FinalCoreResult::ToolsCallInputRequired { result, .. } = sampled else {
panic!("as_proxy must retain upstream sampling input_required: {sampled:?}");
};
assert_live_input_required(&result, "sample", "as_proxy tools/call");
let rooted = runtime_block_on_bounded(
&cx,
admitted.call_tool_result(&cx, &prefixed_roots, json!({})),
)
.expect("as_proxy must forward inbound roots so upstream ctx.final_roots is admitted");
let FinalCoreResult::ToolsCallInputRequired { result, .. } = rooted else {
panic!("as_proxy must retain upstream roots input_required: {rooted:?}");
};
assert_live_input_required(&result, "roots", "as_proxy tools/call");
let prefixed_url = format!("ext/{PUBLIC_HTTP_URL_ELICITATION_TOOL_NAME}");
let elicited = runtime_block_on_bounded(
&cx,
admitted.call_tool_result(&cx, &prefixed_url, json!({})),
)
.expect(
"as_proxy must forward inbound elicitation.url so upstream ctx.final_elicitation_url is admitted",
);
let FinalCoreResult::ToolsCallInputRequired { result, .. } = elicited else {
panic!("as_proxy must retain upstream URL elicitation input_required: {elicited:?}");
};
assert_live_input_required(&result, "approval", "as_proxy tools/call");
let form_resource = runtime_block_on_bounded(
&cx,
admitted.read_resource_result(&cx, PUBLIC_HTTP_ELICITATION_RESOURCE_URI),
)
.expect(
"as_proxy must forward inbound elicitation.form so upstream ctx.final_elicitation_form is admitted",
);
let FinalCoreResult::ResourcesReadInputRequired { result, .. } = form_resource else {
panic!("as_proxy must retain upstream form elicitation input_required: {form_resource:?}");
};
assert_live_input_required(&result, "approval", "as_proxy resources/read");
let prefixed_form_prompt = format!("ext/{PUBLIC_HTTP_ELICITATION_PROMPT_NAME}");
let form_prompt = runtime_block_on_bounded(
&cx,
admitted.get_prompt_result(&cx, &prefixed_form_prompt, HashMap::new()),
)
.expect("as_proxy must forward inbound elicitation.form on prefixed prompts/get");
let FinalCoreResult::PromptsGetInputRequired { result, .. } = form_prompt else {
panic!(
"as_proxy must retain upstream prompt form elicitation input_required: {form_prompt:?}"
);
};
assert_live_input_required(&result, "approval", "as_proxy prompts/get");
let _ = runtime_block_on_bounded(
&cx,
admitted.get_prompt_result(&cx, PUBLIC_HTTP_ELICITATION_PROMPT_NAME, HashMap::new()),
)
.expect_err("changing only the prompt name must not reach the unprefixed elicitation prompt");
let mut no_form_capabilities = ClientCapabilities::default();
no_form_capabilities.elicitation =
serde_json::from_value(json!({"url": {}})).expect("url elicitation capability is valid");
let mut no_form = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-sampling-no-form", "1.0.0")
.capabilities(no_form_capabilities)
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the url-only public facade connects to the live HTTP as_proxy gateway");
let missing_form = runtime_block_on_bounded(
&cx,
no_form.read_resource_result(&cx, PUBLIC_HTTP_ELICITATION_RESOURCE_URI),
);
match missing_form {
Err(error) => assert!(
error.to_string().contains("not advertised by the client"),
"as_proxy must not invent inbound elicitation.form: {error:?}"
),
Ok(result) => panic!("omitting only inbound elicitation.form must fail closed: {result:?}"),
}
let mut no_url_capabilities = ClientCapabilities::default();
no_url_capabilities.sampling = Some(Default::default());
no_url_capabilities.roots =
serde_json::from_value(json!({})).expect("roots capability is valid");
no_url_capabilities.elicitation =
serde_json::from_value(json!({"form": {}})).expect("form elicitation capability is valid");
let mut no_url = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-sampling-no-url", "1.0.0")
.capabilities(no_url_capabilities)
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the form-only public facade connects to the live HTTP as_proxy gateway");
let missing_url = runtime_block_on_bounded(
&cx,
no_url.call_tool_result(&cx, &prefixed_url, json!({})),
)
.expect("omitting only inbound elicitation.url must still complete tools/call as a tool error");
let FinalCoreResult::ToolsCall { result, .. } = missing_url else {
panic!(
"omitting only inbound elicitation.url must fail closed as a tool error: {missing_url:?}"
);
};
assert!(
result.payload.is_error,
"as_proxy must not invent inbound elicitation.url for the upstream handler: {result:?}"
);
let mut bare_capabilities = ClientCapabilities::default();
bare_capabilities.elicitation =
serde_json::from_value(json!({"form": {}})).expect("form elicitation capability is valid");
let mut bare = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-sampling-bare", "1.0.0")
.capabilities(bare_capabilities)
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the bare public facade connects to the live HTTP as_proxy gateway");
let refused =
runtime_block_on_bounded(&cx, bare.call_tool_result(&cx, &prefixed_sample, json!({})))
.expect(
"omitting only inbound sampling must still complete tools/call as a tool error",
);
let FinalCoreResult::ToolsCall { result, .. } = refused else {
panic!("omitting only inbound sampling must fail closed as a tool error: {refused:?}");
};
assert!(
result.payload.is_error,
"as_proxy must not invent inbound sampling for the upstream handler: {result:?}"
);
drop(admitted);
drop(no_form);
drop(no_url);
drop(bare);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_forwards_inbound_sampling_capability() {
let cx = Cx::for_request();
let gateway = spawn_modern_http_stdio_as_proxy_gateway_without_precreated_task();
let mut capabilities = ClientCapabilities::default();
capabilities.sampling = Some(Default::default());
capabilities.roots = serde_json::from_value(json!({})).expect("roots capability is valid");
capabilities.elicitation = serde_json::from_value(json!({"form": {}, "url": {}}))
.expect("form and url elicitation capabilities are valid");
let mut admitted = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-sampling", "1.0.0")
.capabilities(capabilities)
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the sampling public facade connects to the live stdio as_proxy gateway");
let listed = runtime_block_on_bounded(&cx, admitted.list_tools(&cx, None))
.expect("the live stdio as_proxy gateway must advertise the prefixed sampling tool");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == "ext/sample_echo"),
"as_proxy must prefix the shipped echo sampling tool: {listed:?}"
);
let missing = runtime_block_on_bounded(
&cx,
admitted.call_tool_result(&cx, "sample_echo", json!({})),
)
.expect_err("changing only the tool name must not reach the unprefixed stdio sampling handler");
let _ = missing;
let sampled = runtime_block_on_bounded(
&cx,
admitted.call_tool_result(&cx, "ext/sample_echo", json!({})),
)
.expect("stdio as_proxy must forward inbound sampling so echo ctx.final_sampling is admitted");
let FinalCoreResult::ToolsCallInputRequired { result, .. } = sampled else {
panic!("stdio as_proxy must retain echo sampling input_required: {sampled:?}");
};
assert_live_input_required(&result, "sample", "stdio as_proxy tools/call");
let rooted = runtime_block_on_bounded(
&cx,
admitted.call_tool_result(&cx, "ext/roots_echo", json!({})),
)
.expect("stdio as_proxy must forward inbound roots so echo ctx.final_roots is admitted");
let FinalCoreResult::ToolsCallInputRequired { result, .. } = rooted else {
panic!("stdio as_proxy must retain echo roots input_required: {rooted:?}");
};
assert_live_input_required(&result, "roots", "stdio as_proxy tools/call");
let elicited = runtime_block_on_bounded(
&cx,
admitted.call_tool_result(&cx, "ext/url_elicit_echo", json!({})),
)
.expect(
"stdio as_proxy must forward inbound elicitation.url so echo ctx.final_elicitation_url is admitted",
);
let FinalCoreResult::ToolsCallInputRequired { result, .. } = elicited else {
panic!("stdio as_proxy must retain echo URL elicitation input_required: {elicited:?}");
};
assert_live_input_required(&result, "approval", "stdio as_proxy tools/call");
let form_resource = runtime_block_on_bounded(
&cx,
admitted.read_resource_result(&cx, "info://elicit-form"),
)
.expect(
"stdio as_proxy must forward inbound elicitation.form so echo ctx.final_elicitation_form is admitted",
);
let FinalCoreResult::ResourcesReadInputRequired { result, .. } = form_resource else {
panic!(
"stdio as_proxy must retain echo form elicitation input_required: {form_resource:?}"
);
};
assert_live_input_required(&result, "approval", "stdio as_proxy resources/read");
let form_prompt = runtime_block_on_bounded(
&cx,
admitted.get_prompt_result(&cx, "ext/elicit_form_greeting", HashMap::new()),
)
.expect("stdio as_proxy must forward inbound elicitation.form on prefixed prompts/get");
let FinalCoreResult::PromptsGetInputRequired { result, .. } = form_prompt else {
panic!(
"stdio as_proxy must retain echo prompt form elicitation input_required: {form_prompt:?}"
);
};
assert_live_input_required(&result, "approval", "stdio as_proxy prompts/get");
let _ = runtime_block_on_bounded(
&cx,
admitted.get_prompt_result(&cx, "elicit_form_greeting", HashMap::new()),
)
.expect_err(
"changing only the prompt name must not reach the unprefixed echo elicitation prompt",
);
let mut no_form_capabilities = ClientCapabilities::default();
no_form_capabilities.elicitation =
serde_json::from_value(json!({"url": {}})).expect("url elicitation capability is valid");
let mut no_form = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-sampling-no-form", "1.0.0")
.capabilities(no_form_capabilities)
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the url-only public facade connects to the live stdio as_proxy gateway");
let missing_form =
runtime_block_on_bounded(&cx, no_form.read_resource_result(&cx, "info://elicit-form"));
match missing_form {
Err(error) => assert!(
error.to_string().contains("not advertised by the client"),
"stdio as_proxy must not invent inbound elicitation.form: {error:?}"
),
Ok(result) => panic!("omitting only inbound elicitation.form must fail closed: {result:?}"),
}
let mut no_url_capabilities = ClientCapabilities::default();
no_url_capabilities.sampling = Some(Default::default());
no_url_capabilities.roots =
serde_json::from_value(json!({})).expect("roots capability is valid");
no_url_capabilities.elicitation =
serde_json::from_value(json!({"form": {}})).expect("form elicitation capability is valid");
let mut no_url = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-sampling-no-url", "1.0.0")
.capabilities(no_url_capabilities)
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the form-only public facade connects to the live stdio as_proxy gateway");
let missing_url = runtime_block_on_bounded(
&cx,
no_url.call_tool_result(&cx, "ext/url_elicit_echo", json!({})),
)
.expect(
"omitting only inbound elicitation.url must still complete stdio as_proxy tools/call as a tool error",
);
let FinalCoreResult::ToolsCall { result, .. } = missing_url else {
panic!(
"omitting only inbound elicitation.url must fail closed as a stdio as_proxy tool error: {missing_url:?}"
);
};
assert!(
result.payload.is_error,
"stdio as_proxy must not invent inbound elicitation.url for the echo handler: {result:?}"
);
let mut bare_capabilities = ClientCapabilities::default();
bare_capabilities.elicitation =
serde_json::from_value(json!({"form": {}})).expect("form elicitation capability is valid");
let mut bare = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-sampling-bare", "1.0.0")
.capabilities(bare_capabilities)
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the bare public facade connects to the live stdio as_proxy gateway");
let refused = runtime_block_on_bounded(
&cx,
bare.call_tool_result(&cx, "ext/sample_echo", json!({})),
)
.expect("omitting only inbound sampling must still complete stdio as_proxy tools/call as a tool error");
let FinalCoreResult::ToolsCall { result, .. } = refused else {
panic!(
"omitting only inbound sampling must fail closed as a stdio as_proxy tool error: {refused:?}"
);
};
assert!(
result.payload.is_error,
"stdio as_proxy must not invent inbound sampling for the echo handler: {result:?}"
);
drop(admitted);
drop(no_form);
drop(no_url);
drop(bare);
gateway.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_forwards_inbound_progress_marker() {
let cx = Cx::for_request();
let upstream = spawn_modern_progress_http_server();
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let prefixed_tool = format!("ext/{PUBLIC_HTTP_PROGRESS_TOOL_NAME}");
let prefixed_prompt = format!("ext/{PUBLIC_HTTP_PROGRESS_PROMPT_NAME}");
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-progress", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy progress gateway");
runtime_block_on_bounded(&cx, client.call_tool(&cx, &prefixed_tool, json!({})))
.expect("as_proxy tools/call still completes without a progress token");
assert!(
client.take_progress_notifications().is_empty(),
"as_proxy must not invent request-scoped progress without a progressToken: {:?}",
client.take_progress_notifications()
);
let missing = runtime_block_on_bounded(
&cx,
client.call_tool_with_progress_marker(
&cx,
PUBLIC_HTTP_PROGRESS_TOOL_NAME,
json!({}),
modern::ProgressMarker::from("as-proxy-http-progress-unprefixed"),
),
)
.expect_err("changing only the tool name must not reach the unprefixed progress handler");
let _ = missing;
let marker = modern::ProgressMarker::from("as-proxy-http-progress");
runtime_block_on_bounded(
&cx,
client.call_tool_with_progress_marker(&cx, &prefixed_tool, json!({}), marker.clone()),
)
.expect("as_proxy must forward an inbound progressToken onto the upstream tools/call");
let progress = client.take_progress_notifications();
assert!(
progress.iter().any(|notification| {
notification.progress_token == marker
&& notification.message.as_deref() == Some("halfway")
}),
"as_proxy must retain upstream tools/call notifications/progress: {progress:?}"
);
runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_PROGRESS_RESOURCE_URI),
)
.expect("as_proxy resources/read still completes without a progress token");
assert!(
client.take_progress_notifications().is_empty(),
"as_proxy must not invent resource progress without a progressToken"
);
let resource_marker = modern::ProgressMarker::from("as-proxy-http-resource-progress");
runtime_block_on_bounded(
&cx,
client.read_resource_with_progress_marker(
&cx,
PUBLIC_HTTP_PROGRESS_RESOURCE_URI,
resource_marker.clone(),
),
)
.expect("as_proxy must forward an inbound progressToken onto the unprefixed resource URI");
let resource_progress = client.take_progress_notifications();
assert!(
resource_progress.iter().any(|notification| {
notification.progress_token == resource_marker
&& notification.message.as_deref() == Some("resource-halfway")
}),
"as_proxy must retain upstream resources/read notifications/progress: {resource_progress:?}"
);
runtime_block_on_bounded(
&cx,
client.get_prompt(&cx, &prefixed_prompt, HashMap::new()),
)
.expect("as_proxy prompts/get still completes without a progress token");
assert!(
client.take_progress_notifications().is_empty(),
"as_proxy must not invent prompt progress without a progressToken"
);
let prompt_marker = modern::ProgressMarker::from("as-proxy-http-prompt-progress");
runtime_block_on_bounded(
&cx,
client.get_prompt_with_progress_marker(
&cx,
&prefixed_prompt,
HashMap::new(),
prompt_marker.clone(),
),
)
.expect("as_proxy must forward an inbound progressToken onto the prefixed prompt");
let prompt_progress = client.take_progress_notifications();
assert!(
prompt_progress.iter().any(|notification| {
notification.progress_token == prompt_marker
&& notification.message.as_deref() == Some("prompt-halfway")
}),
"as_proxy must retain upstream prompts/get notifications/progress: {prompt_progress:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_forwards_inbound_progress_marker() {
let cx = Cx::for_request();
let gateway = spawn_modern_http_stdio_as_proxy_gateway_without_precreated_task();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-progress", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy progress gateway");
runtime_block_on_bounded(
&cx,
client.call_tool(&cx, "ext/echo", json!({"message": "no-token"})),
)
.expect("stdio as_proxy echo still completes without a progress token");
assert!(
client.take_progress_notifications().is_empty(),
"stdio as_proxy must not invent request-scoped progress without a progressToken"
);
let missing = runtime_block_on_bounded(
&cx,
client.call_tool_with_progress_marker(
&cx,
"echo",
json!({"message": "unprefixed"}),
modern::ProgressMarker::from("as-proxy-stdio-progress-unprefixed"),
),
)
.expect_err("changing only the tool name must not reach the unprefixed stdio echo handler");
let _ = missing;
let marker = modern::ProgressMarker::from("as-proxy-stdio-progress");
runtime_block_on_bounded(
&cx,
client.call_tool_with_progress_marker(
&cx,
"ext/echo",
json!({"message": "token"}),
marker.clone(),
),
)
.expect("stdio as_proxy must forward an inbound progressToken onto ext/echo");
let progress = client.take_progress_notifications();
assert!(
progress.iter().any(|notification| {
notification.progress_token == marker
&& notification.message.as_deref() == Some("echoed")
}),
"stdio as_proxy must retain echo notifications/progress: {progress:?}"
);
runtime_block_on_bounded(&cx, client.read_resource(&cx, "info://server"))
.expect("stdio as_proxy resources/read still completes without a progress token");
assert!(
client.take_progress_notifications().is_empty(),
"stdio as_proxy must not invent resource progress without a progressToken"
);
let resource_marker = modern::ProgressMarker::from("as-proxy-stdio-resource-progress");
runtime_block_on_bounded(
&cx,
client.read_resource_with_progress_marker(&cx, "info://server", resource_marker.clone()),
)
.expect("stdio as_proxy must forward an inbound progressToken onto info://server");
let resource_progress = client.take_progress_notifications();
assert!(
resource_progress.iter().any(|notification| {
notification.progress_token == resource_marker
&& notification.message.as_deref() == Some("info")
}),
"stdio as_proxy must retain info://server notifications/progress: {resource_progress:?}"
);
let mut greeting = HashMap::new();
greeting.insert("name".to_owned(), "no-token".to_owned());
runtime_block_on_bounded(&cx, client.get_prompt(&cx, "ext/greeting", greeting))
.expect("stdio as_proxy prompts/get still completes without a progress token");
assert!(
client.take_progress_notifications().is_empty(),
"stdio as_proxy must not invent prompt progress without a progressToken"
);
let prompt_marker = modern::ProgressMarker::from("as-proxy-stdio-prompt-progress");
let mut greeting_with_token = HashMap::new();
greeting_with_token.insert("name".to_owned(), "token".to_owned());
runtime_block_on_bounded(
&cx,
client.get_prompt_with_progress_marker(
&cx,
"ext/greeting",
greeting_with_token,
prompt_marker.clone(),
),
)
.expect("stdio as_proxy must forward an inbound progressToken onto ext/greeting");
let prompt_progress = client.take_progress_notifications();
assert!(
prompt_progress.iter().any(|notification| {
notification.progress_token == prompt_marker
&& notification.message.as_deref() == Some("greeted")
}),
"stdio as_proxy must retain greeting notifications/progress: {prompt_progress:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_forwards_inbound_completion_progress_marker() {
let cx = Cx::for_request();
let upstream = spawn_modern_facade_http_server(false, None, false);
let gateway = spawn_modern_http_identity_proxy_gateway(upstream.address());
let prefixed = format!("ext/{PUBLIC_HTTP_PROMPT_NAME}");
let params = modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: prefixed,
title: "Public HTTP E2E Prompt".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
context: Some(modern::FinalCompletionContext {
arguments: Some(std::collections::BTreeMap::from([(
"region".to_owned(),
"us-east-1".to_owned(),
)])),
}),
};
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-http-complete-progress", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live HTTP as_proxy completion gateway");
runtime_block_on_bounded(&cx, client.complete(&cx, params.clone()))
.expect("as_proxy completion/complete still completes without a progress token");
assert!(
client.take_progress_notifications().is_empty(),
"as_proxy must not invent completion progress without a progressToken"
);
let mut unprefixed = params.clone();
unprefixed.reference = modern::CompletionReference::PromptWithTitle {
name: PUBLIC_HTTP_PROMPT_NAME.to_owned(),
title: "Public HTTP E2E Prompt".to_owned(),
};
let missing = runtime_block_on_bounded(
&cx,
client.complete_with_progress_marker(
&cx,
unprefixed,
modern::ProgressMarker::from("as-proxy-http-complete-unprefixed"),
),
)
.expect_err("changing only the completion prompt name must not reach the unprefixed provider");
let _ = missing;
let marker = modern::ProgressMarker::from("as-proxy-http-complete-progress");
let result = runtime_block_on_bounded(
&cx,
client.complete_with_progress_marker(&cx, params, marker.clone()),
)
.expect("as_proxy must forward an inbound progressToken onto completion/complete");
assert_eq!(
result.completion.values,
vec![PUBLIC_HTTP_COMPLETION_VALUE.to_owned()],
"as_proxy must still retain the upstream completion values: {result:?}"
);
let progress = client.take_progress_notifications();
assert!(
progress.iter().any(|notification| {
notification.progress_token == marker
&& notification.message.as_deref() == Some("completion-halfway")
}),
"as_proxy must retain upstream completion notifications/progress: {progress:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_as_proxy_stdio_forwards_inbound_completion_progress_marker() {
let cx = Cx::for_request();
let gateway = spawn_modern_http_stdio_as_proxy_gateway_without_precreated_task();
let params = modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: "ext/greeting".to_owned(),
title: "Greeting".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "name".to_owned(),
value: "co".to_owned(),
},
context: Some(modern::FinalCompletionContext {
arguments: Some(std::collections::BTreeMap::from([(
"locale".to_owned(),
"en-US".to_owned(),
)])),
}),
};
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-stdio-complete-progress", "1.0.0")
.connect_http_with_cx(public_http_target(gateway.address(), "/mcp"), &cx),
)
.expect("the public facade connects to the live stdio as_proxy completion gateway");
runtime_block_on_bounded(&cx, client.complete(&cx, params.clone()))
.expect("stdio as_proxy completion/complete still completes without a progress token");
assert!(
client.take_progress_notifications().is_empty(),
"stdio as_proxy must not invent completion progress without a progressToken"
);
let mut unprefixed = params.clone();
unprefixed.reference = modern::CompletionReference::PromptWithTitle {
name: "greeting".to_owned(),
title: "Greeting".to_owned(),
};
let missing = runtime_block_on_bounded(
&cx,
client.complete_with_progress_marker(
&cx,
unprefixed,
modern::ProgressMarker::from("as-proxy-stdio-complete-unprefixed"),
),
)
.expect_err(
"changing only the completion prompt name must not reach the unprefixed stdio provider",
);
let _ = missing;
let marker = modern::ProgressMarker::from("as-proxy-stdio-complete-progress");
let result = runtime_block_on_bounded(
&cx,
client.complete_with_progress_marker(&cx, params, marker.clone()),
)
.expect("stdio as_proxy must forward an inbound progressToken onto ext/greeting completion");
assert_eq!(
result.completion.values,
vec!["stdio-completion-2".to_owned()],
"stdio as_proxy must still retain the echo completion value after the silent call: {result:?}"
);
let progress = client.take_progress_notifications();
assert!(
progress.iter().any(|notification| {
notification.progress_token == marker
&& notification.message.as_deref() == Some("stdio-completion-halfway")
}),
"stdio as_proxy must retain greeting completion notifications/progress: {progress:?}"
);
drop(client);
gateway.shutdown();
}
const PUBLIC_HTTP_LEAK_RESOURCE_URI: &str = "test://public-http-e2e/leak";
const PUBLIC_HTTP_LEAK_SECRET: &str = "secret-db-dsn";
/// Live modern HTTP resource whose execution error carries an internal secret.
struct PublicHttpLeakResource;
impl ResourceHandler for PublicHttpLeakResource {
fn definition(&self) -> Resource {
Resource {
uri: PUBLIC_HTTP_LEAK_RESOURCE_URI.to_owned(),
name: "public-http-e2e-leak".to_owned(),
description: Some("Proves live facade HTTP mask_error_details".to_owned()),
mime_type: Some("text/plain".to_owned()),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
Err(McpError::tool_error(PUBLIC_HTTP_LEAK_SECRET))
}
}
fn spawn_modern_mask_http_server(mask_error_details: bool) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("mask HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-mask", "1.0.0")
.mask_error_details(mask_error_details)
.resource(PublicHttpLeakResource)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("mask facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("mask facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("mask HTTP server startup receiver went away".to_owned());
}
bound
.serve(&cx)
.await
.map_err(|error| format!("mask facade HTTP server stopped unexpectedly: {error}"))
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("mask facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("mask facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("mask facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_mask_error_details_hides_resource_execution_secret() {
let cx = Cx::for_request();
let masked_server = spawn_modern_mask_http_server(true);
let mut masked_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-mask", "1.0.0")
.connect_http_with_cx(public_http_target(masked_server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the masked HTTP server");
let masked = runtime_block_on_bounded(
&cx,
masked_client.read_resource(&cx, PUBLIC_HTTP_LEAK_RESOURCE_URI),
)
.expect_err("a leaking resource must stay a resources/read error");
let masked = format!("{masked:?}");
assert!(
masked.contains("Internal server error"),
"mask_error_details must replace the execution secret: {masked}"
);
assert!(
!masked.contains(PUBLIC_HTTP_LEAK_SECRET),
"mask_error_details must not leak the execution secret: {masked}"
);
drop(masked_client);
masked_server.shutdown();
let unmasked_server = spawn_modern_mask_http_server(false);
let mut unmasked_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-unmask", "1.0.0")
.connect_http_with_cx(public_http_target(unmasked_server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the unmasked HTTP server");
let unmasked = runtime_block_on_bounded(
&cx,
unmasked_client.read_resource(&cx, PUBLIC_HTTP_LEAK_RESOURCE_URI),
)
.expect_err("changing only mask_error_details must still refuse the leaking resource");
let unmasked = format!("{unmasked:?}");
assert!(
unmasked.contains(PUBLIC_HTTP_LEAK_SECRET),
"disabling mask_error_details must keep the execution secret: {unmasked}"
);
drop(unmasked_client);
unmasked_server.shutdown();
}
fn spawn_modern_rate_limit_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("rate-limit HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-rate-limit", "1.0.0")
.middleware(rate_limiting::RateLimitingMiddleware::new(1.0e-300).burst_capacity(1))
.tool(PublicHttpTouchTool)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("rate-limit facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("rate-limit facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("rate-limit HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("rate-limit facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("rate-limit facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("rate-limit facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("rate-limit facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_rate_limit_refuses_second_same_method_and_admits_another() {
let cx = Cx::for_request();
let server = spawn_modern_rate_limit_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-rate-limit", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the rate-limited HTTP server");
let first = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_TOUCH_TOOL_NAME, json!({})),
)
.expect("the first tools/call must be admitted by the live rate limiter");
assert!(
first.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "notified" || text == "silent",
_ => false,
}),
"the first live tools/call must reach the touch handler: {first:?}"
);
let limited = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_TOUCH_TOOL_NAME, json!({})),
)
.expect_err("a second tools/call must be refused by the live rate limiter");
let limited = format!("{limited:?}");
assert!(
limited.contains("Rate limit exceeded"),
"the refused second tools/call must keep the rate-limit error: {limited}"
);
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("changing only the method must still be admitted by the live rate limiter");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOUCH_TOOL_NAME),
"tools/list must stay callable after tools/call is rate-limited: {listed:?}"
);
drop(client);
server.shutdown();
}
fn spawn_modern_sliding_window_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("sliding-window HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-sliding-window", "1.0.0")
.middleware(rate_limiting::SlidingWindowRateLimitingMiddleware::new(
1, 60,
))
.tool(PublicHttpTouchTool)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("sliding-window facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("sliding-window facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("sliding-window HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("sliding-window facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("sliding-window facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("sliding-window facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("sliding-window facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_sliding_window_refuses_second_same_method_and_admits_another() {
let cx = Cx::for_request();
let server = spawn_modern_sliding_window_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-sliding-window", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the sliding-window HTTP server");
runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_TOUCH_TOOL_NAME, json!({})),
)
.expect("the first tools/call must be admitted by the live sliding window");
let limited = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_TOUCH_TOOL_NAME, json!({})),
)
.expect_err("a second tools/call must be refused by the live sliding window");
let limited = format!("{limited:?}");
assert!(
limited.contains("Rate limit exceeded"),
"the refused second tools/call must keep the sliding-window error: {limited}"
);
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("changing only the method must still be admitted by the live sliding window");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOUCH_TOOL_NAME),
"tools/list must stay callable after tools/call is sliding-window limited: {listed:?}"
);
drop(client);
server.shutdown();
}
const PUBLIC_HTTP_TRANSFORMED_TOOL_NAME: &str = "public-http-e2e-lookup";
fn spawn_modern_transform_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("transform HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-transform", "1.0.0")
.tool(
transform::TransformedTool::from_tool(PublicHttpValue)
.name(PUBLIC_HTTP_TRANSFORMED_TOOL_NAME)
.rename_arg("value", "query")
.build(),
)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("transform facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("transform facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("transform HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("transform facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("transform facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("transform facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("transform facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_transformed_tool_renames_catalog_and_argument() {
let cx = Cx::for_request();
let server = spawn_modern_transform_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-transform", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the transformed-tool HTTP server");
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("live bind_http must list the transformed tool");
let transformed = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_TRANSFORMED_TOOL_NAME)
.unwrap_or_else(|| {
panic!("the transformed catalog must advertise the new name: {listed:?}")
});
assert!(
listed
.tools
.iter()
.all(|tool| tool.name != PUBLIC_HTTP_TOOL_NAME),
"the parent tool name must leave the transformed catalog: {listed:?}"
);
let schema = serde_json::to_string(&transformed.input_schema)
.expect("the transformed input schema serializes");
assert!(
schema.contains("query"),
"the transformed schema must advertise the renamed argument: {schema}"
);
assert!(
!schema.contains("\"value\""),
"the transformed schema must drop the original argument name: {schema}"
);
let looked_up = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_TRANSFORMED_TOOL_NAME,
json!({"query": "alpha"}),
),
)
.expect("the transformed tools/call must rewrite query back to value");
assert!(
looked_up.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the rewritten argument must reach the parent handler: {looked_up:?}"
);
let original = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.expect_err("the parent tool name must stay unadvertised");
let original = format!("{original:?}");
assert!(
original.contains("Unknown tool")
|| original.contains("Method not found")
|| original.contains("MethodNotFound"),
"calling the pre-transform name must stay unknown: {original}"
);
drop(client);
server.shutdown();
}
const PUBLIC_HTTP_HIDDEN_TOOL_NAME: &str = "public-http-e2e-hidden";
fn spawn_modern_hide_arg_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("hide-arg HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-hide-arg", "1.0.0")
.tool(
transform::TransformedTool::from_tool(PublicHttpValue)
.name(PUBLIC_HTTP_HIDDEN_TOOL_NAME)
.hide_arg("value", "hidden-default")
.build(),
)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("hide-arg facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("hide-arg facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("hide-arg HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("hide-arg facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("hide-arg facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("hide-arg facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("hide-arg facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_transformed_tool_hides_argument_and_injects_default() {
let cx = Cx::for_request();
let server = spawn_modern_hide_arg_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-hide-arg", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the hide-arg HTTP server");
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("live bind_http must list the hide-arg tool");
let hidden = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_HIDDEN_TOOL_NAME)
.unwrap_or_else(|| panic!("the hide-arg catalog must advertise the new name: {listed:?}"));
let schema =
serde_json::to_string(&hidden.input_schema).expect("the hide-arg input schema serializes");
assert!(
!schema.contains("\"value\""),
"the hide-arg schema must drop the hidden argument: {schema}"
);
let injected = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_HIDDEN_TOOL_NAME, json!({})),
)
.expect("the hide-arg tools/call must inject the configured default");
assert!(
injected.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:hidden-default",
_ => false,
}),
"the hidden default must reach the parent handler: {injected:?}"
);
let original = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.expect_err("the parent tool name must stay unadvertised");
let original = format!("{original:?}");
assert!(
original.contains("Unknown tool")
|| original.contains("Method not found")
|| original.contains("MethodNotFound"),
"calling the pre-transform name must stay unknown: {original}"
);
drop(client);
server.shutdown();
}
fn spawn_modern_strict_validation_http_server(strict: bool) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("strict-validation HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-strict-validation", "1.0.0")
.strict_input_validation(strict)
.tool(PublicHttpValue)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message =
format!("strict-validation facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("strict-validation facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("strict-validation HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("strict-validation facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("strict-validation facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => {
panic!("strict-validation facade HTTP server failed to start: {error}")
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("strict-validation facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_strict_input_validation_refuses_unknown_property_and_admits_declared_args() {
let cx = Cx::for_request();
let strict_server = spawn_modern_strict_validation_http_server(true);
let mut strict_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-strict-on", "1.0.0")
.connect_http_with_cx(public_http_target(strict_server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the strict-validation HTTP server");
let admitted = runtime_block_on_bounded(
&cx,
strict_client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.expect("strict validation must still admit declared arguments");
assert!(
!admitted.is_error,
"declared arguments must not become a tool-level error: {admitted:?}"
);
assert!(
admitted.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"declared arguments must reach the handler under strict validation: {admitted:?}"
);
let refused = runtime_block_on_bounded(
&cx,
strict_client.call_tool(
&cx,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha", "extra": 1}),
),
)
.expect("strict validation returns a complete tools/call result, not a transport failure");
assert!(
refused.is_error,
"an unknown property must become a tool-level error when strict validation is on: {refused:?}"
);
assert!(
refused.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text.contains("do not match the declared input schema")
}
_ => false,
}),
"the strict refusal must name the input-schema mismatch: {refused:?}"
);
drop(strict_client);
strict_server.shutdown();
let lenient_server = spawn_modern_strict_validation_http_server(false);
let mut lenient_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-strict-off", "1.0.0")
.connect_http_with_cx(public_http_target(lenient_server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the lenient-validation HTTP server");
let extra = runtime_block_on_bounded(
&cx,
lenient_client.call_tool(
&cx,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha", "extra": 1}),
),
)
.expect("lenient validation must admit the same extra property");
assert!(
!extra.is_error,
"changing only the strict flag must admit the extra property: {extra:?}"
);
assert!(
extra.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the extra property must not change the handler result when strict is off: {extra:?}"
);
drop(lenient_client);
lenient_server.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_official_tasks_create_get_and_cancel_on_bind_http() {
let cx = Cx::for_request();
let server = spawn_modern_task_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-direct-tasks", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the official Tasks HTTP server");
let created = runtime_block_on_bounded(
&cx,
client.call_tool_outcome(
&cx,
RequestId::Number(2),
PUBLIC_HTTP_TASK_TOOL_NAME,
json!({}),
1 << 20,
),
)
.expect("live bind_http must create one official Task");
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the task-capable live tool must return the official Task result branch: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
let input_deadline = Instant::now() + HTTP_OPERATION_BOUND;
let mut next_id = 3_i64;
let observed = loop {
let observed = runtime_block_on_bounded(
&cx,
client.get_task(&cx, RequestId::Number(next_id), task_id.clone(), 1 << 20),
)
.expect("live bind_http must serve tasks/get of the created Task");
next_id = next_id
.checked_add(1)
.expect("the official Tasks request id space does not overflow");
if matches!(observed.task, FinalTask::InputRequired { .. }) {
break observed;
}
if Instant::now() >= input_deadline {
panic!("live bind_http must observe the created input_required Task: {observed:?}");
}
thread::sleep(Duration::from_millis(5));
};
assert_eq!(
observed.task.base().task_id,
task_id,
"tasks/get must return the created Task: {observed:?}"
);
let missing = FinalTaskId::parse("missing-direct-task")
.expect("the planted-missing task id is a valid official TaskId");
let missing_get = runtime_block_on_bounded(
&cx,
client.get_task(&cx, RequestId::Number(next_id), missing.clone(), 1 << 20),
)
.expect_err("changing only the task id must not invent a Task");
let _ = missing_get;
next_id += 1;
let missing_cancel = runtime_block_on_bounded(
&cx,
client.cancel_task(&cx, RequestId::Number(next_id), missing, 1 << 20),
)
.expect_err("changing only the task id must not invent a cancellation");
let _ = missing_cancel;
next_id += 1;
let wrong_update: FinalTaskInputResponses =
serde_json::from_value(json!({"roots": {"action": "accept"}}))
.expect("the planted-wrong Tasks update payload is typed");
let rejected_update = runtime_block_on_bounded(
&cx,
client.update_task(
&cx,
RequestId::Number(next_id),
&observed.task,
wrong_update,
1 << 20,
),
)
.expect_err("changing only the input response kind must not resume the created Task");
let _ = rejected_update;
next_id += 1;
let still_waiting = runtime_block_on_bounded(
&cx,
client.get_task(&cx, RequestId::Number(next_id), task_id.clone(), 1 << 20),
)
.expect("a rejected tasks/update must leave the created Task in place");
next_id += 1;
assert!(
matches!(still_waiting.task, FinalTask::InputRequired { .. }),
"a rejected tasks/update must not leave the input_required Task: {still_waiting:?}"
);
let responses: FinalTaskInputResponses =
serde_json::from_value(json!({"roots": {"roots": []}}))
.expect("the public final roots response is typed");
runtime_block_on_bounded(
&cx,
client.update_task(
&cx,
RequestId::Number(next_id),
&observed.task,
responses,
1 << 20,
),
)
.expect("live bind_http must serve tasks/update of the created Task");
next_id += 1;
let working_deadline = Instant::now() + HTTP_OPERATION_BOUND;
loop {
let observed = runtime_block_on_bounded(
&cx,
client.get_task(&cx, RequestId::Number(next_id), task_id.clone(), 1 << 20),
)
.expect("live bind_http must keep serving tasks/get after update");
next_id = next_id
.checked_add(1)
.expect("the official Tasks request id space does not overflow");
if matches!(observed.task, FinalTask::Working(_)) {
break;
}
if Instant::now() >= working_deadline {
panic!(
"live bind_http must observe the working Task after a matching update: {observed:?}"
);
}
thread::sleep(Duration::from_millis(5));
}
runtime_block_on_bounded(
&cx,
client.cancel_task(&cx, RequestId::Number(next_id), task_id.clone(), 1 << 20),
)
.expect("live bind_http must serve tasks/cancel of the created Task");
next_id += 1;
let cancel_deadline = Instant::now() + HTTP_OPERATION_BOUND;
loop {
let observed = runtime_block_on_bounded(
&cx,
client.get_task(&cx, RequestId::Number(next_id), task_id.clone(), 1 << 20),
)
.expect("live bind_http must keep serving tasks/get after cancel");
next_id = next_id
.checked_add(1)
.expect("the official Tasks request id space does not overflow");
if matches!(observed.task, FinalTask::Cancelled(_)) {
break;
}
if Instant::now() >= cancel_deadline {
panic!("live bind_http must observe the cancelled Task: {observed:?}");
}
thread::sleep(Duration::from_millis(5));
}
drop(client);
server.shutdown();
}
fn spawn_modern_duplicate_http_server(behavior: DuplicateBehavior) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("duplicate HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-duplicate", "1.0.0")
.on_duplicate(behavior)
.tool(PublicHttpValue)
.tool(ReplacedPublicHttpValue)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("duplicate facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("duplicate facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("duplicate HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("duplicate facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("duplicate facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("duplicate facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("duplicate facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_on_duplicate_error_keeps_first_and_replace_installs_second() {
let cx = Cx::for_request();
let keep_server = spawn_modern_duplicate_http_server(DuplicateBehavior::Error);
let mut keep_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-duplicate-error", "1.0.0")
.connect_http_with_cx(public_http_target(keep_server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the Error-on-duplicate HTTP server");
let kept = runtime_block_on_bounded(
&cx,
keep_client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.expect("Error must keep the first registration callable");
assert!(
kept.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"on_duplicate Error must keep the first handler: {kept:?}"
);
drop(keep_client);
keep_server.shutdown();
let replace_server = spawn_modern_duplicate_http_server(DuplicateBehavior::Replace);
let mut replace_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-duplicate-replace", "1.0.0")
.connect_http_with_cx(public_http_target(replace_server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the Replace-on-duplicate HTTP server");
let replaced = runtime_block_on_bounded(
&cx,
replace_client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.expect("Replace must install the second registration");
assert!(
replaced.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "replaced:alpha",
_ => false,
}),
"changing only on_duplicate to Replace must reach the second handler: {replaced:?}"
);
drop(replace_client);
replace_server.shutdown();
}
fn spawn_modern_cache_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let tool_calls = Arc::clone(&handler_calls);
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("cache HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-cache", "1.0.0")
.middleware(
caching::ResponseCachingMiddleware::new()
.include_tools(vec![PUBLIC_HTTP_TOOL_NAME.to_owned()]),
)
.tool(CountingPublicHttpValue {
counters: tool_calls,
})
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("cache facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("cache facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("cache HTTP server startup receiver went away".to_owned());
}
bound
.serve(&cx)
.await
.map_err(|error| format!("cache facade HTTP server stopped unexpectedly: {error}"))
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("cache facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("cache facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("cache facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_response_cache_hits_same_allowlisted_call_and_misses_changed_args() {
let cx = Cx::for_request();
let server = spawn_modern_cache_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-cache", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the cached HTTP server");
let first = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.expect("the first allowlisted tools/call must miss and reach the handler");
assert!(
first.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the first live tools/call must return the handler result: {first:?}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"the first allowlisted tools/call must invoke the counting handler"
);
let cached = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.expect("the second identical allowlisted tools/call must be a cache hit");
assert!(
cached.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the cache hit must keep the first complete result: {cached:?}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"a cache hit must not invoke the counting handler again"
);
let missed = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "beta"})),
)
.expect("changing only the arguments must miss the cache");
assert!(
missed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:beta",
_ => false,
}),
"the cache miss must reach the handler with the new arguments: {missed:?}"
);
assert_eq!(
server.handler_call_snapshot().tool,
2,
"a different-arguments tools/call must invoke the counting handler"
);
drop(client);
server.shutdown();
}
const PUBLIC_HTTP_SLOW_TOOL_NAME: &str = "public-http-e2e-slow";
const PUBLIC_HTTP_FAST_TOOL_NAME: &str = "public-http-e2e-fast";
/// Live modern HTTP tool whose handler timeout expires before its delay finishes.
struct PublicHttpSlowTool;
impl ToolHandler for PublicHttpSlowTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_SLOW_TOOL_NAME.to_owned(),
description: Some("Proves live facade HTTP handler timeout".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn timeout(&self) -> Option<Duration> {
Some(Duration::from_millis(10))
}
fn call(&self, _ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
thread::sleep(Duration::from_millis(80));
Ok(vec![Content::text("late")])
}
}
/// Live modern HTTP peer tool that stays inside the request budget.
struct PublicHttpFastTool;
impl ToolHandler for PublicHttpFastTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_FAST_TOOL_NAME.to_owned(),
description: Some(
"Proves live facade HTTP handler timeout does not starve peers".to_owned(),
),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, _ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
Ok(vec![Content::text("fast")])
}
}
const PUBLIC_HTTP_HOLD_TOOL_NAME: &str = "public-http-e2e-hold";
/// Sleeps longer than a 1s gateway `request_timeout` and has no handler `timeout()`.
struct PublicHttpHoldTool;
impl ToolHandler for PublicHttpHoldTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_HOLD_TOOL_NAME.to_owned(),
description: Some(
"Proves live facade HTTP request_timeout of a tool without timeout()".to_owned(),
),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, _ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
thread::sleep(Duration::from_millis(1500));
Ok(vec![Content::text("held")])
}
}
const PUBLIC_HTTP_CLIENT_IDENTITY_TOOL_NAME: &str = "public-http-e2e-client-identity";
/// Live modern HTTP/WebSocket tool that reports handler-visible client Implementation extras.
struct PublicHttpClientIdentity;
impl ToolHandler for PublicHttpClientIdentity {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_CLIENT_IDENTITY_TOOL_NAME.to_owned(),
description: Some(
"Proves live facade HTTP client Implementation extras reach handlers".to_owned(),
),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
let text = match ctx.client_implementation() {
Some(identity) => format!(
"name={}|title={}",
identity.name,
identity.title.as_deref().unwrap_or("none")
),
None => "missing".to_owned(),
};
Ok(vec![Content::text(text)])
}
}
const PUBLIC_HTTP_PANIC_TOOL_NAME: &str = "public-http-e2e-panic";
const PUBLIC_HTTP_PANIC_PAYLOAD: &str = "planted-handler-panic-payload";
/// Live HTTP/WebSocket tool whose handler unwinds with a distinctive payload.
struct PublicHttpPanicTool;
impl ToolHandler for PublicHttpPanicTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_PANIC_TOOL_NAME.to_owned(),
description: Some("Proves live facade HTTP handler panic sanitization".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, _ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
panic!("{PUBLIC_HTTP_PANIC_PAYLOAD}")
}
}
const PUBLIC_HTTP_PANIC_RESOURCE_URI: &str = "test://public-http-e2e/panic";
const PUBLIC_HTTP_PANIC_PROMPT_NAME: &str = "public-http-e2e-panic-prompt";
/// Live HTTP/WebSocket resource whose handler unwinds with a distinctive payload.
struct PublicHttpPanicResource;
impl ResourceHandler for PublicHttpPanicResource {
fn definition(&self) -> Resource {
Resource {
uri: PUBLIC_HTTP_PANIC_RESOURCE_URI.to_owned(),
name: "panic-resource".to_owned(),
description: Some("Proves live facade HTTP resource panic sanitization".to_owned()),
mime_type: Some("text/plain".to_owned()),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn read(&self, _context: &McpContext) -> McpResult<Vec<ResourceContent>> {
panic!("{PUBLIC_HTTP_PANIC_PAYLOAD}")
}
}
/// Live HTTP/WebSocket prompt whose handler unwinds with a distinctive payload.
struct PublicHttpPanicPrompt;
impl PromptHandler for PublicHttpPanicPrompt {
fn definition(&self) -> Prompt {
Prompt {
name: PUBLIC_HTTP_PANIC_PROMPT_NAME.to_owned(),
description: Some("Proves live facade HTTP prompt panic sanitization".to_owned()),
arguments: Vec::new(),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn get(
&self,
_ctx: &McpContext,
_arguments: HashMap<String, String>,
) -> McpResult<Vec<PromptMessage>> {
panic!("{PUBLIC_HTTP_PANIC_PAYLOAD}")
}
}
const PUBLIC_HTTP_COMPLETE_PEER_PROMPT_NAME: &str = "public-http-e2e-complete-peer";
/// Peer prompt that declares a completion argument so final dispatch reaches
/// the panic completion handler instead of refusing before invocation.
struct PublicHttpCompletePeerPrompt;
impl PromptHandler for PublicHttpCompletePeerPrompt {
fn definition(&self) -> Prompt {
Prompt {
name: PUBLIC_HTTP_COMPLETE_PEER_PROMPT_NAME.to_owned(),
description: Some("Peer prompt for live completion panic sanitization".to_owned()),
arguments: vec![PromptArgument {
name: "subject".to_owned(),
description: Some("Declared completion argument".to_owned()),
required: false,
}],
icon: None,
version: None,
tags: Vec::new(),
}
}
fn get(
&self,
_ctx: &McpContext,
_arguments: HashMap<String, String>,
) -> McpResult<Vec<PromptMessage>> {
Ok(vec![PromptMessage {
role: Role::User,
content: Content::text("complete-peer"),
}])
}
}
/// Live HTTP/WebSocket completion whose handler unwinds with a distinctive payload.
struct PublicHttpPanicCompletion;
impl CompletionHandler for PublicHttpPanicCompletion {
fn complete_legacy(
&self,
_ctx: &McpContext,
_params: legacy_2024::LegacyCompletionParams,
) -> McpResult<legacy_2024::CompletionValues> {
panic!("{PUBLIC_HTTP_PANIC_PAYLOAD}")
}
fn complete_final(
&self,
_ctx: &McpContext,
_params: modern::FinalCompletionParams,
) -> McpResult<modern::FinalCompletionValues> {
panic!("{PUBLIC_HTTP_PANIC_PAYLOAD}")
}
}
fn public_http_panic_completion_params() -> modern::CompletionParams {
modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: PUBLIC_HTTP_COMPLETE_PEER_PROMPT_NAME.to_owned(),
title: "Complete Peer".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
context: None,
}
}
fn spawn_modern_handler_timeout_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("handler-timeout HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-handler-timeout", "1.0.0")
.tool(PublicHttpSlowTool)
.tool(PublicHttpFastTool)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message =
format!("handler-timeout facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("handler-timeout facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("handler-timeout HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("handler-timeout facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("handler-timeout facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("handler-timeout facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("handler-timeout facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_handler_timeout_refuses_late_tool_and_admits_fast_peer() {
let cx = Cx::for_request();
let server = spawn_modern_handler_timeout_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-handler-timeout", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the handler-timeout HTTP server");
let timed_out = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_SLOW_TOOL_NAME, json!({})),
)
.expect_err("a handler that outlives its timeout must be refused");
let timed_out = format!("{timed_out:?}");
assert!(
timed_out.contains("Request timeout exceeded") || timed_out.contains("RequestCancelled"),
"the refused late tools/call must keep the handler-timeout error: {timed_out}"
);
let fast = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.expect("changing only the tool must still be admitted after a handler timeout");
assert!(
fast.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"the fast peer tool must still complete: {fast:?}"
);
drop(client);
server.shutdown();
}
fn spawn_modern_handler_panic_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("handler-panic HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-handler-panic", "1.0.0")
.tool(PublicHttpPanicTool)
.tool(PublicHttpFastTool)
.resource(PublicHttpPanicResource)
.resource(PublicHttpPlainResource)
.prompt(PublicHttpPanicPrompt)
.prompt(PublicHttpPlainPrompt)
.prompt(PublicHttpCompletePeerPrompt)
.prompt_completion_handler(
PUBLIC_HTTP_COMPLETE_PEER_PROMPT_NAME,
PublicHttpPanicCompletion,
)
.completion_handler(PublicHttpPanicCompletion)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("handler-panic facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("handler-panic facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("handler-panic HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("handler-panic facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("handler-panic facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("handler-panic facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("handler-panic facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_handler_panic_is_sanitized_and_admits_fast_peer() {
let cx = Cx::for_request();
let server = spawn_modern_handler_panic_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-handler-panic", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the handler-panic HTTP server");
let panicked = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_PANIC_TOOL_NAME, json!({})),
)
.expect_err("a panicking tools/call must stay a protocol error");
let panicked = format!("{panicked:?}");
assert!(
panicked.contains("Internal server error") || panicked.contains("InternalError"),
"a handler panic must become the sanitized InternalError: {panicked}"
);
assert!(
!panicked.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"a handler panic must not leak the unwind payload: {panicked}"
);
let fast = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.expect("changing only the tool must still be admitted after a sanitized panic");
assert!(
fast.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"the fast peer tool must still complete after a sanitized panic: {fast:?}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_catalog_panic_is_sanitized_and_admits_fast_peer() {
let cx = Cx::for_request();
let server = spawn_modern_handler_panic_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-catalog-panic", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the catalog-panic HTTP server");
let panicked_resource = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_PANIC_RESOURCE_URI),
)
.expect_err("a panicking resources/read must stay a protocol error");
let panicked_resource = format!("{panicked_resource:?}");
assert!(
panicked_resource.contains("Internal server error")
|| panicked_resource.contains("InternalError"),
"a resource panic must become the sanitized InternalError: {panicked_resource}"
);
assert!(
!panicked_resource.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"a resource panic must not leak the unwind payload: {panicked_resource}"
);
let panicked_prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(&cx, PUBLIC_HTTP_PANIC_PROMPT_NAME, HashMap::new()),
)
.expect_err("a panicking prompts/get must stay a protocol error");
let panicked_prompt = format!("{panicked_prompt:?}");
assert!(
panicked_prompt.contains("Internal server error")
|| panicked_prompt.contains("InternalError"),
"a prompt panic must become the sanitized InternalError: {panicked_prompt}"
);
assert!(
!panicked_prompt.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"a prompt panic must not leak the unwind payload: {panicked_prompt}"
);
let peer_resource = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_PLAIN_RESOURCE_URI),
)
.expect("changing only the resource must still be admitted after a sanitized panic");
let peer_resource = format!("{peer_resource:?}");
assert!(
peer_resource.contains("plain-resource"),
"the peer resource must still complete after a sanitized catalog panic: {peer_resource}"
);
let peer_prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(&cx, PUBLIC_HTTP_PLAIN_PROMPT_NAME, HashMap::new()),
)
.expect("changing only the prompt must still be admitted after a sanitized panic");
let peer_prompt = format!("{peer_prompt:?}");
assert!(
peer_prompt.contains("plain")
|| peer_prompt.contains("icon-prompt")
|| !peer_prompt.is_empty(),
"the peer prompt must still complete after a sanitized catalog panic: {peer_prompt}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_completion_panic_is_sanitized_and_admits_fast_peer() {
let cx = Cx::for_request();
let server = spawn_modern_handler_panic_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-completion-panic", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the completion-panic HTTP server");
let panicked = runtime_block_on_bounded(
&cx,
client.complete(&cx, public_http_panic_completion_params()),
)
.expect_err("a panicking completion/complete must stay a protocol error");
let panicked = format!("{panicked:?}");
assert!(
panicked.contains("Internal server error") || panicked.contains("InternalError"),
"a completion panic must become the sanitized InternalError: {panicked}"
);
assert!(
!panicked.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"a completion panic must not leak the unwind payload: {panicked}"
);
let peer_prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(&cx, PUBLIC_HTTP_COMPLETE_PEER_PROMPT_NAME, HashMap::new()),
)
.expect(
"changing only the method must still admit prompts/get after a sanitized completion panic",
);
let peer_prompt = format!("{peer_prompt:?}");
assert!(
peer_prompt.contains("complete-peer"),
"the peer prompt must still complete after a sanitized completion panic: {peer_prompt}"
);
let fast = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.expect(
"changing only the method must still admit tools/call after a sanitized completion panic",
);
assert!(
fast.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"the fast peer tool must still complete after a sanitized completion panic: {fast:?}"
);
drop(client);
server.shutdown();
}
const PUBLIC_HTTP_TEMPLATE_COMPLETION_VALUE: &str = "alice";
const PUBLIC_HTTP_TEMPLATE_COMPLETION_LEGACY_VALUE: &str = "http-note-completion-legacy";
struct PublicHttpTemplateCompletion;
impl CompletionHandler for PublicHttpTemplateCompletion {
fn complete_legacy(
&self,
_context: &McpContext,
params: legacy_2024::LegacyCompletionParams,
) -> McpResult<legacy_2024::CompletionValues> {
match ¶ms.reference {
legacy_2024::LegacyCompletionReference::Resource { uri }
if uri == PUBLIC_HTTP_CURSOR_TEMPLATE && params.argument.name == "id" => {}
_ => {
return Err(McpError::invalid_params(
"template completion requires the exact resource template",
));
}
}
Ok(legacy_2024::CompletionValues {
values: vec![PUBLIC_HTTP_TEMPLATE_COMPLETION_LEGACY_VALUE.to_owned()],
total: Some(1),
has_more: Some(false),
})
}
fn complete_final(
&self,
_context: &McpContext,
params: modern::FinalCompletionParams,
) -> McpResult<modern::FinalCompletionValues> {
if !matches!(
¶ms.reference,
modern::FinalCompletionReference::Resource { uri }
if uri == PUBLIC_HTTP_CURSOR_TEMPLATE
) || params.argument.name != "id"
|| params.argument.value != "al"
{
return Err(McpError::invalid_params(
"template completion requires the exact modern request shape",
));
}
Ok(modern::FinalCompletionValues {
values: vec![PUBLIC_HTTP_TEMPLATE_COMPLETION_VALUE.to_owned()],
total: Some(modern::JsonInteger::from(1_i64)),
has_more: Some(false),
})
}
}
fn public_http_template_completion_params() -> modern::CompletionParams {
modern::CompletionParams {
reference: modern::CompletionReference::Resource {
uri: PUBLIC_HTTP_CURSOR_TEMPLATE.to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "id".to_owned(),
value: "al".to_owned(),
},
context: None,
}
}
fn spawn_modern_template_completion_http_server() -> HttpServerFixture {
spawn_legacy_http_server("modern template completion", || {
ServerBuilder::new("facade-http-template-complete", "1.0.0")
.protocol_policy(ProtocolPolicy::ModernOnly)
.expect("ModernOnly is available")
.tool(PublicHttpFastTool)
.resource(PublicHttpCursorTemplateResource)
.resource(PublicHttpOtherTemplateResource)
.resource_template_completion_handler(
PUBLIC_HTTP_CURSOR_TEMPLATE,
PublicHttpTemplateCompletion,
)
.build()
})
}
fn spawn_legacy_template_completion_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy template completion", || {
ServerBuilder::new("facade-http-legacy-template-complete", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpFastTool)
.resource(PublicHttpCursorTemplateResource)
.resource(PublicHttpOtherTemplateResource)
.legacy_resource_template_completion_handler(
PUBLIC_HTTP_CURSOR_TEMPLATE,
PublicHttpTemplateCompletion,
)
.build()
})
}
#[test]
fn e2e_public_http_resource_template_completion_is_retained_and_unregistered_template_is_refused() {
let cx = Cx::for_request();
let server = spawn_modern_template_completion_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-template-complete", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the template-completion HTTP server");
let advertised = discover_capability_map(&client);
assert!(
advertised.get("completions").is_some(),
"installing a resource-template completion provider must advertise completions: {advertised}"
);
let result = runtime_block_on_bounded(
&cx,
client.complete(&cx, public_http_template_completion_params()),
)
.expect("live bind_http must complete the registered resource template provider");
assert_eq!(
result.completion.values,
vec![PUBLIC_HTTP_TEMPLATE_COMPLETION_VALUE.to_owned()],
"the exact FinalCompletionResult retains the template provider value: {result:?}"
);
let mut undeclared = public_http_template_completion_params();
undeclared.argument.name = "undeclared".to_owned();
let undeclared = runtime_block_on_bounded(&cx, client.complete(&cx, undeclared))
.expect_err("only an undeclared template variable is rejected");
assert!(matches!(
undeclared,
modern::HttpClientError::CoreResult(error) if error.code == McpErrorCode::InvalidParams
));
let mut other_template = public_http_template_completion_params();
other_template.reference = modern::CompletionReference::Resource {
uri: PUBLIC_HTTP_OTHER_TEMPLATE.to_owned(),
};
let missing = runtime_block_on_bounded(&cx, client.complete(&cx, other_template))
.expect_err("changing only the template URI must refuse a missing provider");
assert!(matches!(
missing,
modern::HttpClientError::CoreResult(error) if error.code == McpErrorCode::InvalidParams
));
let fast = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.expect("changing only the method must still admit tools/call after template completion");
assert!(
fast.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"the fast peer tool must still complete after template completion: {fast:?}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_resource_and_prompt_list_changed_are_retained_on_incremental_listen() {
let cx = Cx::for_request();
let server = spawn_modern_subscription_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-catalog-changed", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects before subscriptions/listen");
let limits = modern::SseLimits::new(64 * 1024, 2 * 1024 * 1024, 256)
.expect("subscription SSE limits must be nonzero");
let filter = modern::SubscriptionFilter {
resources_list_changed: Some(true),
prompts_list_changed: Some(true),
..modern::SubscriptionFilter::default()
};
runtime_block_on_bounded(
&cx,
client.start_subscriptions_listener(&cx, filter, limits),
)
.expect("live bind_http must admit an incremental subscriptions/listen");
let acknowledgement = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("subscriptions/listen must emit its acknowledgement");
assert!(
matches!(
acknowledgement,
Some(modern::ModernHttpSubscriptionListenEvent::Acknowledged { .. })
),
"the first incremental listen record must be the accepted filter: {acknowledgement:?}"
);
let hidden = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_HIDE_CATALOG_TOOL_NAME, json!({})),
)
.expect("disabling a resource and a prompt must complete");
assert!(
hidden.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "hidden",
_ => false,
}),
"request-local SessionState must let disable_resource and disable_prompt publish: {hidden:?}"
);
let first = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("subscriptions/listen must retain the first catalog mutation");
let second = runtime_block_on_bounded(&cx, client.next_http_subscription_event(&cx))
.expect("subscriptions/listen must retain the second catalog mutation");
let kinds = [first, second];
assert!(
kinds.iter().any(|event| matches!(
event,
Some(modern::ModernHttpSubscriptionListenEvent::Notification(
modern::ServerNotification::ResourcesListChanged(_)
))
)),
"live bind_http must retain notifications/resources/list_changed: {kinds:?}"
);
assert!(
kinds.iter().any(|event| matches!(
event,
Some(modern::ModernHttpSubscriptionListenEvent::Notification(
modern::ServerNotification::PromptsListChanged(_)
))
)),
"live bind_http must retain notifications/prompts/list_changed: {kinds:?}"
);
drop(client);
server.shutdown();
}
fn spawn_modern_auth_admission_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let tool_calls = Arc::clone(&handler_calls);
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("auth admission HTTP server control receiver went away".to_owned());
}
let verifier = StaticTokenVerifier::new([(
"alpha",
AuthContext::with_subject(PUBLIC_HTTP_AUTH_SUBJECT),
)])
.expect("the deterministic native bearer verifier is valid")
.with_allowed_schemes(["Bearer"])
.expect("the bearer scheme allowlist is valid");
let server = modern::ServerBuilder::new("facade-http-auth-admission", "1.0.0")
.auth_provider(TokenAuthProvider::new(verifier))
.tool(PublicHttpAuthSubject {
counters: tool_calls,
})
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("auth admission facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("auth admission facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("auth admission HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("auth admission facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("auth admission facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("auth admission facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("auth admission facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_static_token_refuses_missing_and_wrong_and_commits_subject() {
const MAX_NATIVE_HTTP_RESPONSE_BYTES: usize = 1 << 20;
fn exchange(address: SocketAddr, authorization: Option<&str>, body: &[u8]) -> Vec<u8> {
let mut stream = std::net::TcpStream::connect_timeout(&address, HTTP_OPERATION_BOUND)
.expect("native HTTP client connects to the auth admission listener");
stream
.set_read_timeout(Some(HTTP_OPERATION_BOUND))
.expect("native HTTP client read deadline is configured");
stream
.set_write_timeout(Some(HTTP_OPERATION_BOUND))
.expect("native HTTP client write deadline is configured");
let authorization_header =
authorization.map_or_else(String::new, |value| format!("Authorization: {value}\r\n"));
let request = format!(
"POST /mcp HTTP/1.1\r\nHost: {address}\r\n{authorization_header}Accept: application/json\r\nContent-Type: application/json\r\nMCP-Protocol-Version: {}\r\nMcp-Method: tools/call\r\nMcp-Name: {PUBLIC_HTTP_AUTH_TOOL_NAME}\r\nContent-Length: {}\r\n\r\n",
modern::PROTOCOL_VERSION,
body.len(),
);
stream
.write_all(request.as_bytes())
.and_then(|()| stream.write_all(body))
.expect("native HTTP request commits to the auth admission listener");
let mut response = Vec::new();
let mut buffer = [0_u8; 8 * 1024];
loop {
let read = stream
.read(&mut buffer)
.expect("native HTTP response reads within its configured deadline");
if read == 0 {
break;
}
assert!(
response
.len()
.checked_add(read)
.is_some_and(|size| size <= MAX_NATIVE_HTTP_RESPONSE_BYTES),
"native HTTP response exceeds the test's bounded response budget"
);
response.extend_from_slice(&buffer[..read]);
}
response
}
fn response_headers(response: &[u8]) -> &str {
let header_end = response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("native HTTP response contains a complete header terminator");
std::str::from_utf8(&response[..header_end])
.expect("native HTTP response headers are ASCII")
}
fn response_json_body(response: &[u8]) -> serde_json::Value {
let header_end = response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("native HTTP response contains a complete header terminator");
let headers = std::str::from_utf8(&response[..header_end])
.expect("native HTTP response headers are ASCII");
let mut content_length = None;
let mut chunked = false;
for header in headers.lines().skip(1) {
let (name, value) = header
.split_once(':')
.expect("native HTTP response header has a field delimiter");
if name.eq_ignore_ascii_case("content-length") {
content_length = Some(
value
.trim()
.parse::<usize>()
.expect("native HTTP Content-Length is a valid byte count"),
);
}
if name.eq_ignore_ascii_case("transfer-encoding")
&& value
.split(',')
.any(|coding| coding.trim().eq_ignore_ascii_case("chunked"))
{
chunked = true;
}
}
let body = &response[header_end + 4..];
let decoded = if chunked {
let mut cursor = 0;
let mut decoded = Vec::new();
loop {
let size_end = body[cursor..]
.windows(2)
.position(|window| window == b"\r\n")
.map(|offset| cursor + offset)
.expect("chunked response contains a complete chunk-size line");
let size_line = std::str::from_utf8(&body[cursor..size_end])
.expect("chunked response chunk size is ASCII");
let size =
usize::from_str_radix(size_line.split(';').next().unwrap_or_default(), 16)
.expect("chunked response chunk size is hexadecimal");
cursor = size_end + 2;
if size == 0 {
return serde_json::from_slice(&decoded)
.expect("authenticated tool response is JSON-RPC");
}
let chunk_end = cursor
.checked_add(size)
.expect("chunked response chunk length does not overflow");
decoded.extend_from_slice(&body[cursor..chunk_end]);
cursor = chunk_end + 2;
}
} else {
let content_length = content_length
.expect("native HTTP response uses Content-Length or chunked framing");
body[..content_length].to_vec()
};
serde_json::from_slice(&decoded).expect("authenticated tool response is JSON-RPC")
}
let server = spawn_modern_auth_admission_http_server();
let tool = JsonRpcRequest::new(
"tools/call",
Some(json!({
"name": PUBLIC_HTTP_AUTH_TOOL_NAME,
"arguments": {},
"_meta": {
"io.modelcontextprotocol/protocolVersion": modern::PROTOCOL_VERSION,
"io.modelcontextprotocol/clientCapabilities": {},
},
})),
2_i64,
);
let tool_body = serde_json::to_vec(&tool).expect("exact-modern tool request serializes");
let missing = exchange(server.address(), None, &tool_body);
assert!(
missing.starts_with(b"HTTP/1.1 401"),
"missing Authorization must be refused before dispatch: {}",
String::from_utf8_lossy(&missing)
);
assert!(
response_headers(&missing).lines().any(|header| header
.to_ascii_lowercase()
.starts_with("www-authenticate:")
&& header.to_ascii_lowercase().contains("bearer")),
"the missing-token challenge must advertise Bearer: {}",
String::from_utf8_lossy(&missing)
);
assert_eq!(
server.handler_call_snapshot().tool,
0,
"missing Authorization must not invoke the authenticated handler"
);
let wrong = exchange(server.address(), Some("Bearer beta"), &tool_body);
assert!(
wrong.starts_with(b"HTTP/1.1 401"),
"changing only the bearer token must be refused before dispatch: {}",
String::from_utf8_lossy(&wrong)
);
assert_eq!(
server.handler_call_snapshot().tool,
0,
"a wrong bearer token must not invoke the authenticated handler"
);
let admitted = exchange(server.address(), Some("Bearer alpha"), &tool_body);
assert!(
admitted.starts_with(b"HTTP/1.1 200"),
"the matching bearer token must admit tools/call: {}",
String::from_utf8_lossy(&admitted)
);
let admitted = response_json_body(&admitted);
assert!(
admitted.get("error").is_none(),
"the matching bearer token must reach the handler: {admitted}"
);
let content = admitted["result"]["content"]
.as_array()
.cloned()
.unwrap_or_default();
assert!(
content.iter().any(|block| {
block["text"].as_str() == Some(&format!("subject:{PUBLIC_HTTP_AUTH_SUBJECT}"))
}),
"admitted native HTTP must commit the verifier subject into ctx.auth(): {admitted}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"only the matching bearer token may invoke the authenticated handler"
);
server.shutdown();
}
fn spawn_modern_catalog_metadata_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("catalog metadata HTTP server control receiver went away".to_owned());
}
let icons = vec![
RawIcon::try_new(PUBLIC_HTTP_ICON_SRC).expect("the catalog icon source is valid"),
];
let server = modern::ServerBuilder::new("facade-http-catalog-metadata", "1.0.0")
.tool(PublicHttpIconTool {
icons: icons.clone(),
})
.tool(PublicHttpPlainTool)
.resource(PublicHttpIconResource {
icons: icons.clone(),
})
.resource(PublicHttpPlainResource)
.prompt(PublicHttpIconPrompt { icons })
.prompt(PublicHttpPlainPrompt)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message =
format!("catalog metadata facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("catalog metadata facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("catalog metadata HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("catalog metadata facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("catalog metadata facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => {
panic!("catalog metadata facade HTTP server failed to start: {error}")
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("catalog metadata facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_list_tools_retains_final_icons_title_and_annotations() {
let cx = Cx::for_request();
let server = spawn_modern_catalog_metadata_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-catalog-metadata", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the catalog metadata HTTP server");
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("live bind_http must list the catalog metadata tools");
let icon_tool = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_ICON_TOOL_NAME)
.expect("the icon tool must remain on the live catalog");
let plain_tool = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_PLAIN_TOOL_NAME)
.expect("the plain peer tool must remain on the live catalog");
assert_eq!(icon_tool.title.as_deref(), Some("Icon Tool"));
let icons = icon_tool
.icons
.as_ref()
.expect("the icon tool must advertise its exact-final icons");
assert_eq!(icons.len(), 1);
assert_eq!(icons[0].src.as_str(), PUBLIC_HTTP_ICON_SRC);
let annotations = icon_tool
.annotations
.as_ref()
.expect("the icon tool must retain its projected annotations");
assert_eq!(annotations.read_only, Some(true));
assert_eq!(annotations.idempotent, Some(true));
assert_eq!(annotations.destructive, None);
assert_eq!(plain_tool.title, None);
assert_eq!(plain_tool.icons, None);
assert_eq!(plain_tool.annotations, None);
let listed_resources = runtime_block_on_bounded(&cx, client.list_resources(&cx, None))
.expect("live bind_http must list the catalog metadata resources");
let icon_resource = listed_resources
.resources
.iter()
.find(|resource| resource.uri.as_str() == PUBLIC_HTTP_ICON_RESOURCE_URI)
.expect("the icon resource must remain on the live catalog");
let plain_resource = listed_resources
.resources
.iter()
.find(|resource| resource.uri.as_str() == PUBLIC_HTTP_PLAIN_RESOURCE_URI)
.expect("the plain peer resource must remain on the live catalog");
assert_eq!(icon_resource.title.as_deref(), Some("Icon Resource"));
let resource_icons = icon_resource
.icons
.as_ref()
.expect("the icon resource must advertise its exact-final icons");
assert_eq!(resource_icons.len(), 1);
assert_eq!(resource_icons[0].src.as_str(), PUBLIC_HTTP_ICON_SRC);
assert_eq!(plain_resource.title, None);
assert_eq!(plain_resource.icons, None);
let listed_prompts = runtime_block_on_bounded(&cx, client.list_prompts(&cx, None))
.expect("live bind_http must list the catalog metadata prompts");
let icon_prompt = listed_prompts
.prompts
.iter()
.find(|prompt| prompt.name == PUBLIC_HTTP_ICON_PROMPT_NAME)
.expect("the icon prompt must remain on the live catalog");
let plain_prompt = listed_prompts
.prompts
.iter()
.find(|prompt| prompt.name == PUBLIC_HTTP_PLAIN_PROMPT_NAME)
.expect("the plain peer prompt must remain on the live catalog");
assert_eq!(icon_prompt.title.as_deref(), Some("Icon Prompt"));
let prompt_icons = icon_prompt
.icons
.as_ref()
.expect("the icon prompt must advertise its exact-final icons");
assert_eq!(prompt_icons.len(), 1);
assert_eq!(prompt_icons[0].src.as_str(), PUBLIC_HTTP_ICON_SRC);
assert_eq!(plain_prompt.title, None);
assert_eq!(plain_prompt.icons, None);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_two_clients_call_the_same_tool_independently() {
let cx = Cx::for_request();
let server = spawn_modern_facade_http_server(false, None, false);
let mut first = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-multi-client-a", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the first ModernOnly public facade connects to the shared HTTP server");
let mut second = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-multi-client-b", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the second ModernOnly public facade connects to the same HTTP server");
let first_result = runtime_block_on_bounded(
&cx,
first.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.expect("the first live client must reach the shared tool");
let second_result = runtime_block_on_bounded(
&cx,
second.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "beta"})),
)
.expect("the second live client must reach the shared tool independently");
assert!(
first_result.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the first client must keep its own tools/call result: {first_result:?}"
);
assert!(
second_result.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:beta",
_ => false,
}),
"changing only the second client's arguments must not reuse the first result: {second_result:?}"
);
assert_eq!(
server.handler_call_snapshot().tool,
2,
"two independent HTTP clients must each invoke the shared handler"
);
drop(first);
drop(second);
server.shutdown();
}
#[test]
fn e2e_public_http_ping_then_tools_call_on_bind_http() {
let cx = Cx::for_request();
let server = spawn_modern_facade_http_server(false, None, false);
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-ping", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects before ping");
runtime_block_on_bounded(&cx, client.ping(&cx)).expect("live bind_http must answer ping");
assert_eq!(
server.handler_call_snapshot().tool,
0,
"ping must not invoke a tools/call handler"
);
let called = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.expect("tools/call must still be admitted after ping");
assert!(
called.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"a later tools/call must keep its handler result after ping: {called:?}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"only the later tools/call may invoke the handler"
);
drop(client);
server.shutdown();
}
fn spawn_modern_output_schema_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("output schema HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-output-schema", "1.0.0")
.tool(PublicHttpOutputTool)
.tool(PublicHttpPlainTool)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("output schema facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("output schema facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("output schema HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("output schema facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("output schema facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("output schema facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("output schema facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_output_schema_retains_structured_content_and_peer_stays_bare() {
let cx = Cx::for_request();
let server = spawn_modern_output_schema_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-output-schema", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the output-schema HTTP server");
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("live bind_http must list the output-schema tools");
let output_tool = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_OUTPUT_TOOL_NAME)
.expect("the output-schema tool must remain on the live catalog");
let plain_tool = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_PLAIN_TOOL_NAME)
.expect("the bare peer tool must remain on the live catalog");
assert_eq!(
output_tool
.output_schema
.as_ref()
.and_then(|schema| schema.get("required")),
Some(&json!(["value"])),
"tools/list must retain the advertised output schema: {output_tool:?}"
);
assert_eq!(
plain_tool.output_schema, None,
"changing only the missing output schema must keep the peer bare: {plain_tool:?}"
);
let called = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_OUTPUT_TOOL_NAME, json!({"value": "alpha"})),
)
.expect("live bind_http must admit the structured output tool");
assert!(
called.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the structured tool must still author text content: {called:?}"
);
assert_eq!(
called.structured_content,
Some(json!({"value": "alpha"})),
"tools/call must retain structuredContent matching the advertised schema: {called:?}"
);
let peer = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_PLAIN_TOOL_NAME, json!({})),
)
.expect("the bare peer tool must still be callable");
assert_eq!(
peer.structured_content, None,
"changing only the missing output schema must not invent structuredContent: {peer:?}"
);
drop(client);
server.shutdown();
}
fn spawn_modern_compose_and_state_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let tool_calls = Arc::clone(&handler_calls);
let resource_calls = Arc::clone(&handler_calls);
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("compose HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-compose", "1.0.0")
.tool(CountingPublicHttpValue {
counters: Arc::clone(&tool_calls),
})
.tool(PublicHttpComposeTool)
.tool(PublicHttpMacroComposeTool)
.tool(PublicHttpStateTool)
.resource(CountingPublicHttpSnapshot {
counters: resource_calls,
})
.resource(PublicHttpMacroComposeViewResource)
.resource(PublicHttpMacroComposeMissingResource)
.resource(PublicHttpComposeResource {
uri: PUBLIC_HTTP_COMPOSE_RESOURCE_URI,
tool: PUBLIC_HTTP_TOOL_NAME,
resource: PUBLIC_HTTP_RESOURCE_URI,
})
.resource(PublicHttpComposeResource {
uri: PUBLIC_HTTP_COMPOSE_MISSING_TOOL_RESOURCE_URI,
tool: "public-http-e2e-missing",
resource: PUBLIC_HTTP_RESOURCE_URI,
})
.resource(PublicHttpComposeResource {
uri: PUBLIC_HTTP_COMPOSE_MISSING_RESOURCE_URI,
tool: PUBLIC_HTTP_TOOL_NAME,
resource: "test://public-http-e2e/missing",
})
.prompt(PublicHttpComposePrompt)
.prompt(PublicHttpMacroComposePrompt)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("compose facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("compose facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("compose HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("compose facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("compose facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("compose facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("compose facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_handler_ctx_call_tool_and_read_resource() {
let cx = Cx::for_request();
let server = spawn_modern_compose_and_state_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-compose", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the compose HTTP server");
let composed = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_COMPOSE_TOOL_NAME,
json!({"value": "alpha"}),
),
)
.expect("live bind_http must compose a nested tool call and resource read");
assert!(
composed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text == "compose:tool:alpha|resource:deterministic"
}
_ => false,
}),
"ctx.call_tool and ctx.read_resource must reach the live peer handlers: {composed:?}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"the nested tools/call must invoke the peer tool exactly once"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"the nested resources/read must invoke the peer resource exactly once"
);
let missing_tool = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_COMPOSE_TOOL_NAME,
json!({"value": "alpha", "tool": "public-http-e2e-missing"}),
),
);
let missing_tool = match missing_tool {
Ok(result) if result.is_error => format!("{result:?}"),
Err(error) => format!("{error:?}"),
Ok(result) => panic!(
"changing only the nested tool name must refuse before the peer handlers run: {result:?}"
),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("Unknown tool")
|| missing_tool.contains("not found")
|| missing_tool.contains("MethodNotFound")
|| missing_tool.contains("compose-nested-tool"),
"the nested unknown tool must stay a handler-visible refusal: {missing_tool}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"an unknown nested tool name must not invoke the peer tool"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"an unknown nested tool name must not reach the peer resource"
);
let missing_resource = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_COMPOSE_TOOL_NAME,
json!({
"value": "beta",
"resource": "test://public-http-e2e/missing"
}),
),
);
let missing_resource = match missing_resource {
Ok(result) if result.is_error => format!("{result:?}"),
Err(error) => format!("{error:?}"),
Ok(result) => panic!(
"changing only the nested resource URI must refuse after the peer tool runs: {result:?}"
),
};
assert!(
missing_resource.contains("test://public-http-e2e/missing")
|| missing_resource.contains("not found")
|| missing_resource.contains("ResourceNotFound")
|| missing_resource.contains("compose-nested-resource"),
"the nested unknown resource must stay a handler-visible refusal: {missing_resource}"
);
assert_eq!(
server.handler_call_snapshot().tool,
2,
"the peer tool must still run when only the nested resource URI changes"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"an unknown nested resource URI must not invoke the peer resource"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_prompt_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let server = spawn_modern_compose_and_state_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-prompt-compose", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the prompt compose HTTP server");
let composed = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
PUBLIC_HTTP_COMPOSE_PROMPT_NAME,
HashMap::from([("value".to_owned(), "alpha".to_owned())]),
),
)
.expect("live bind_http prompts/get must compose a nested tool call and resource read");
assert!(
composed
.messages
.iter()
.any(|message| match &message.content {
ContentBlock::Text { text, .. } => {
text == "compose:tool:alpha|resource:deterministic"
}
_ => false,
}),
"prompt ctx.call_tool and ctx.read_resource must reach the live peer handlers: {composed:?}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"the nested tools/call must invoke the peer tool exactly once"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"the nested resources/read must invoke the peer resource exactly once"
);
let missing_tool = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
PUBLIC_HTTP_COMPOSE_PROMPT_NAME,
HashMap::from([
("value".to_owned(), "alpha".to_owned()),
("tool".to_owned(), "public-http-e2e-missing".to_owned()),
]),
),
);
let missing_tool = match missing_tool {
Ok(result) => panic!(
"changing only the nested tool name must refuse before the peer handlers run: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("Unknown tool")
|| missing_tool.contains("not found")
|| missing_tool.contains("MethodNotFound")
|| missing_tool.contains("compose-nested-tool"),
"the nested unknown tool must stay a handler-visible refusal: {missing_tool}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"an unknown nested tool name must not invoke the peer tool"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"an unknown nested tool name must not reach the peer resource"
);
let missing_resource = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
PUBLIC_HTTP_COMPOSE_PROMPT_NAME,
HashMap::from([
("value".to_owned(), "beta".to_owned()),
(
"resource".to_owned(),
"test://public-http-e2e/missing".to_owned(),
),
]),
),
);
let missing_resource = match missing_resource {
Ok(result) => panic!(
"changing only the nested resource URI must refuse after the peer tool runs: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_resource.contains("test://public-http-e2e/missing")
|| missing_resource.contains("not found")
|| missing_resource.contains("ResourceNotFound")
|| missing_resource.contains("compose-nested-resource"),
"the nested unknown resource must stay a handler-visible refusal: {missing_resource}"
);
assert_eq!(
server.handler_call_snapshot().tool,
2,
"the peer tool must still run when only the nested resource URI changes"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"an unknown nested resource URI must not invoke the peer resource"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_macro_prompt_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let server = spawn_modern_compose_and_state_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-macro-compose", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the macro prompt compose HTTP server");
let composed = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
PUBLIC_HTTP_MACRO_COMPOSE_PROMPT_NAME,
HashMap::from([
("value".to_owned(), "alpha".to_owned()),
("tool".to_owned(), PUBLIC_HTTP_TOOL_NAME.to_owned()),
("resource".to_owned(), PUBLIC_HTTP_RESOURCE_URI.to_owned()),
]),
),
)
.expect("live bind_http async #[prompt] must compose a nested tool call and resource read");
assert!(
composed
.messages
.iter()
.any(|message| match &message.content {
ContentBlock::Text { text, .. } => {
text == "compose:tool:alpha|resource:deterministic"
}
_ => false,
}),
"async #[prompt] ctx.call_tool and ctx.read_resource must reach the live peer handlers: {composed:?}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"the nested tools/call must invoke the peer tool exactly once"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"the nested resources/read must invoke the peer resource exactly once"
);
let missing_tool = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
PUBLIC_HTTP_MACRO_COMPOSE_PROMPT_NAME,
HashMap::from([
("value".to_owned(), "alpha".to_owned()),
("tool".to_owned(), "public-http-e2e-missing".to_owned()),
("resource".to_owned(), PUBLIC_HTTP_RESOURCE_URI.to_owned()),
]),
),
);
let missing_tool = match missing_tool {
Ok(result) => panic!(
"changing only the nested tool name must refuse before the peer handlers run: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("Unknown tool")
|| missing_tool.contains("not found")
|| missing_tool.contains("MethodNotFound")
|| missing_tool.contains("compose-nested-tool"),
"the nested unknown tool must stay a handler-visible refusal: {missing_tool}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"an unknown nested tool name must not invoke the peer tool"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_macro_tool_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let server = spawn_modern_compose_and_state_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-macro-tool-compose", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the macro tool compose HTTP server");
let composed = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_MACRO_COMPOSE_TOOL_NAME,
json!({
"value": "alpha",
"tool": PUBLIC_HTTP_TOOL_NAME,
"resource": PUBLIC_HTTP_RESOURCE_URI
}),
),
)
.expect("live bind_http async #[tool] must compose a nested tool call and resource read");
assert!(
composed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text == "compose:tool:alpha|resource:deterministic"
}
_ => false,
}),
"async #[tool] ctx.call_tool and ctx.read_resource must reach the live peer handlers: {composed:?}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"the nested tools/call must invoke the peer tool exactly once"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"the nested resources/read must invoke the peer resource exactly once"
);
let missing_tool = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_MACRO_COMPOSE_TOOL_NAME,
json!({
"value": "alpha",
"tool": "public-http-e2e-missing",
"resource": PUBLIC_HTTP_RESOURCE_URI
}),
),
);
let missing_tool = match missing_tool {
Ok(result) if result.is_error => format!("{result:?}"),
Err(error) => format!("{error:?}"),
Ok(result) => panic!(
"changing only the nested tool name must refuse before the peer handlers run: {result:?}"
),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("Unknown tool")
|| missing_tool.contains("not found")
|| missing_tool.contains("MethodNotFound")
|| missing_tool.contains("compose-nested-tool"),
"the nested unknown tool must stay a handler-visible refusal: {missing_tool}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"an unknown nested tool name must not invoke the peer tool"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_macro_resource_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let server = spawn_modern_compose_and_state_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-macro-resource-compose", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the macro resource compose HTTP server");
let composed = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_MACRO_COMPOSE_RESOURCE_URI),
)
.expect("live bind_http async #[resource] must compose a nested tool call and resource read");
assert!(
matches!(
composed.contents.as_slice(),
[EmbeddedResourceContents::Text { text, .. }]
if text == "compose:tool:alpha|resource:deterministic"
),
"async #[resource] ctx.call_tool and ctx.read_resource must reach the live peer handlers: {composed:?}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"the nested tools/call must invoke the peer tool exactly once"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"the nested resources/read must invoke the peer resource exactly once"
);
let missing_tool = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_MACRO_COMPOSE_MISSING_TOOL_RESOURCE_URI),
);
let missing_tool = match missing_tool {
Ok(result) => panic!(
"changing only the nested tool name must refuse before the peer handlers run: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("Unknown tool")
|| missing_tool.contains("not found")
|| missing_tool.contains("MethodNotFound")
|| missing_tool.contains("compose-nested-tool"),
"the nested unknown tool must stay a handler-visible refusal: {missing_tool}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"an unknown nested tool name must not invoke the peer tool"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_resource_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let server = spawn_modern_compose_and_state_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-resource-compose", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the resource compose HTTP server");
let composed = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_COMPOSE_RESOURCE_URI),
)
.expect("live bind_http resources/read must compose a nested tool call and resource read");
assert!(
matches!(
composed.contents.as_slice(),
[EmbeddedResourceContents::Text { text, .. }]
if text == "compose:tool:alpha|resource:deterministic"
),
"resource ctx.call_tool and ctx.read_resource must reach the live peer handlers: {composed:?}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"the nested tools/call must invoke the peer tool exactly once"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"the nested resources/read must invoke the peer resource exactly once"
);
let missing_tool = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_COMPOSE_MISSING_TOOL_RESOURCE_URI),
);
let missing_tool = match missing_tool {
Ok(result) => panic!(
"changing only the nested tool name must refuse before the peer handlers run: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("Unknown tool")
|| missing_tool.contains("not found")
|| missing_tool.contains("MethodNotFound")
|| missing_tool.contains("compose-nested-tool"),
"the nested unknown tool must stay a handler-visible refusal: {missing_tool}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"an unknown nested tool name must not invoke the peer tool"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"an unknown nested tool name must not reach the peer resource"
);
let missing_resource = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_COMPOSE_MISSING_RESOURCE_URI),
);
let missing_resource = match missing_resource {
Ok(result) => panic!(
"changing only the nested resource URI must refuse after the peer tool runs: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_resource.contains("test://public-http-e2e/missing")
|| missing_resource.contains("not found")
|| missing_resource.contains("ResourceNotFound")
|| missing_resource.contains("compose-nested-resource"),
"the nested unknown resource must stay a handler-visible refusal: {missing_resource}"
);
assert_eq!(
server.handler_call_snapshot().tool,
2,
"the peer tool must still run when only the nested resource URI changes"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"an unknown nested resource URI must not invoke the peer resource"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_json_only_accept_composes_nested_tool_and_resource() {
const MAX_NATIVE_HTTP_RESPONSE_BYTES: usize = 1 << 20;
fn exchange(address: SocketAddr, body: &[u8]) -> Vec<u8> {
let mut stream = std::net::TcpStream::connect_timeout(&address, HTTP_OPERATION_BOUND)
.expect("JSON-only client connects to the compose listener");
stream
.set_read_timeout(Some(HTTP_OPERATION_BOUND))
.expect("JSON-only client read deadline is configured");
stream
.set_write_timeout(Some(HTTP_OPERATION_BOUND))
.expect("JSON-only client write deadline is configured");
let request = format!(
"POST /mcp HTTP/1.1\r\nHost: {address}\r\nAccept: application/json\r\nContent-Type: application/json\r\nMCP-Protocol-Version: {}\r\nMcp-Method: tools/call\r\nMcp-Name: {PUBLIC_HTTP_COMPOSE_TOOL_NAME}\r\nContent-Length: {}\r\n\r\n",
modern::PROTOCOL_VERSION,
body.len(),
);
stream
.write_all(request.as_bytes())
.and_then(|()| stream.write_all(body))
.expect("JSON-only compose request commits");
let mut response = Vec::new();
let mut buffer = [0_u8; 8 * 1024];
loop {
let read = stream
.read(&mut buffer)
.expect("JSON-only compose response reads within its deadline");
if read == 0 {
break;
}
assert!(
response
.len()
.checked_add(read)
.is_some_and(|size| size <= MAX_NATIVE_HTTP_RESPONSE_BYTES),
"JSON-only compose response exceeds the bounded budget"
);
response.extend_from_slice(&buffer[..read]);
}
response
}
fn response_json_body(response: &[u8]) -> serde_json::Value {
let header_end = response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("JSON-only response contains a header terminator");
let headers = std::str::from_utf8(&response[..header_end])
.expect("JSON-only response headers are ASCII");
let body = &response[header_end + 4..];
let content_length = headers.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("content-length") {
value.trim().parse::<usize>().ok()
} else {
None
}
});
let decoded = if headers.lines().any(|line| {
line.to_ascii_lowercase()
.contains("transfer-encoding: chunked")
}) {
let mut cursor = 0;
let mut decoded = Vec::new();
loop {
let size_end = body[cursor..]
.windows(2)
.position(|window| window == b"\r\n")
.map(|offset| cursor + offset)
.expect("chunked JSON response contains a chunk-size line");
let size_line =
std::str::from_utf8(&body[cursor..size_end]).expect("chunk size is ASCII");
let size =
usize::from_str_radix(size_line.split(';').next().unwrap_or_default(), 16)
.expect("chunk size is hexadecimal");
cursor = size_end + 2;
if size == 0 {
break decoded;
}
let chunk_end = cursor
.checked_add(size)
.expect("chunk length does not overflow");
decoded.extend_from_slice(&body[cursor..chunk_end]);
cursor = chunk_end + 2;
}
} else {
let content_length =
content_length.expect("JSON response uses Content-Length or chunked framing");
body[..content_length].to_vec()
};
serde_json::from_slice(&decoded).expect("JSON-only compose response is JSON-RPC")
}
let server = spawn_modern_compose_and_state_http_server();
let tool = JsonRpcRequest::new(
"tools/call",
Some(json!({
"name": PUBLIC_HTTP_COMPOSE_TOOL_NAME,
"arguments": {"value": "alpha"},
"_meta": {
"io.modelcontextprotocol/protocolVersion": modern::PROTOCOL_VERSION,
"io.modelcontextprotocol/clientCapabilities": {},
},
})),
7_i64,
);
let tool_body = serde_json::to_vec(&tool).expect("JSON-only compose request serializes");
let admitted = exchange(server.address(), &tool_body);
assert!(
admitted.starts_with(b"HTTP/1.1 200"),
"JSON-only Accept must admit composed tools/call: {}",
String::from_utf8_lossy(&admitted)
);
let admitted = response_json_body(&admitted);
assert!(
admitted.get("error").is_none(),
"JSON-only compose must not be a JSON-RPC error: {admitted}"
);
let content = admitted["result"]["content"]
.as_array()
.cloned()
.unwrap_or_default();
assert!(
content.iter().any(|block| {
block["text"].as_str() == Some("compose:tool:alpha|resource:deterministic")
}),
"JSON-only Accept must compose through the same request-owned callers: {admitted}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"JSON-only compose must invoke the nested tool once"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"JSON-only compose must invoke the nested resource once"
);
let missing = JsonRpcRequest::new(
"tools/call",
Some(json!({
"name": PUBLIC_HTTP_COMPOSE_TOOL_NAME,
"arguments": {"value": "alpha", "tool": "public-http-e2e-missing"},
"_meta": {
"io.modelcontextprotocol/protocolVersion": modern::PROTOCOL_VERSION,
"io.modelcontextprotocol/clientCapabilities": {},
},
})),
8_i64,
);
let missing_body =
serde_json::to_vec(&missing).expect("JSON-only missing-tool request serializes");
let refused = exchange(server.address(), &missing_body);
let refused = response_json_body(&refused);
let refused_text = format!("{refused:?}");
assert!(
refused_text.contains("public-http-e2e-missing")
|| refused_text.contains("compose-nested-tool"),
"JSON-only compose must keep the nested unknown tool visible: {refused}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"a nested unknown tool name must not invoke the peer tool"
);
server.shutdown();
}
#[test]
fn e2e_public_http_session_state_is_request_local_across_posts() {
let cx = Cx::for_request();
let server = spawn_modern_compose_and_state_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-state", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the session-state HTTP server");
let written = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_STATE_TOOL_NAME,
json!({"action": "write", "value": "alpha"}),
),
)
.expect("live bind_http must write request-local session state");
assert!(
written.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "state:alpha",
_ => false,
}),
"set_state then get_state on the same POST must retain the value: {written:?}"
);
let later_read = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_STATE_TOOL_NAME, json!({"action": "read"})),
)
.expect("a later POST must still be admitted");
assert!(
later_read.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "state:missing",
_ => false,
}),
"changing only the POST must not reuse the previous request-local bag: {later_read:?}"
);
let removed = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_STATE_TOOL_NAME,
json!({"action": "write", "value": "beta"}),
),
)
.expect("a later write must still succeed on a fresh request-local bag");
assert!(
removed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "state:beta",
_ => false,
}),
"a later POST write must see only its own bag: {removed:?}"
);
drop(client);
server.shutdown();
}
fn spawn_modern_caps_http_server(with_resource: bool) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("caps HTTP server control receiver went away".to_owned());
}
let builder =
modern::ServerBuilder::new("facade-http-caps", "1.0.0").tool(PublicHttpCapsTool);
let builder = if with_resource {
builder.resource(PublicHttpSnapshotResource)
} else {
builder
};
let server = builder.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("caps facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("caps facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("caps HTTP server startup receiver went away".to_owned());
}
bound
.serve(&cx)
.await
.map_err(|error| format!("caps facade HTTP server stopped unexpectedly: {error}"))
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("caps facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("caps facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("caps facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
fn advertised_caps_text(result: &FinalCallToolResult) -> String {
result
.content
.iter()
.find_map(|content| match content {
ContentBlock::Text { text, .. } => Some(text.clone()),
_ => None,
})
.unwrap_or_else(|| format!("missing text: {result:?}"))
}
#[test]
fn e2e_public_http_handler_sees_client_and_server_capabilities() {
let cx = Cx::for_request();
let with_resources = spawn_modern_caps_http_server(true);
let without_resources = spawn_modern_caps_http_server(false);
let mut default_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-caps-default", "1.0.0")
.connect_http_with_cx(public_http_target(with_resources.address(), "/mcp"), &cx),
)
.expect("the default ModernOnly client connects to the capability HTTP server");
let default = runtime_block_on_bounded(
&cx,
default_client.call_tool(&cx, PUBLIC_HTTP_CAPS_TOOL_NAME, json!({})),
)
.expect("default client tools/call must reach the capability handler");
assert_eq!(
advertised_caps_text(&default),
"caps:sampling=false;roots=false;tools=true;resources=true",
"a default client must not invent sampling/roots, and a resource-bearing server must advertise both"
);
let mut sampling_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-caps-sampling", "1.0.0")
.capabilities(ClientCapabilities {
sampling: Some(Default::default()),
elicitation: None,
roots: Some(Default::default()),
})
.connect_http_with_cx(public_http_target(with_resources.address(), "/mcp"), &cx),
)
.expect("the sampling/roots ModernOnly client connects to the capability HTTP server");
let sampling = runtime_block_on_bounded(
&cx,
sampling_client.call_tool(&cx, PUBLIC_HTTP_CAPS_TOOL_NAME, json!({})),
)
.expect("sampling client tools/call must reach the capability handler");
assert_eq!(
advertised_caps_text(&sampling),
"caps:sampling=true;roots=true;tools=true;resources=true",
"advertising only sampling and roots must flip those client flags without changing the server slice"
);
let mut bare_server_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-caps-bare", "1.0.0")
.connect_http_with_cx(public_http_target(without_resources.address(), "/mcp"), &cx),
)
.expect("the default client connects to the tool-only capability HTTP server");
let bare = runtime_block_on_bounded(
&cx,
bare_server_client.call_tool(&cx, PUBLIC_HTTP_CAPS_TOOL_NAME, json!({})),
)
.expect("tool-only server tools/call must reach the capability handler");
assert_eq!(
advertised_caps_text(&bare),
"caps:sampling=false;roots=false;tools=true;resources=false",
"omitting the resource registration must clear only the handler-visible resources flag"
);
drop(default_client);
drop(sampling_client);
drop(bare_server_client);
with_resources.shutdown();
without_resources.shutdown();
}
fn spawn_modern_instructions_http_server(with_instructions: bool) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("instructions HTTP server control receiver went away".to_owned());
}
let builder = modern::ServerBuilder::new("facade-http-instructions", "1.0.0")
.tool(PublicHttpValue);
let builder = if with_instructions {
builder.instructions(PUBLIC_HTTP_INSTRUCTIONS)
} else {
builder
};
let server = builder.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("instructions facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("instructions facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("instructions HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("instructions facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("instructions facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("instructions facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("instructions facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
fn spawn_modern_lifespan_http_server(
startup: Option<Arc<AtomicBool>>,
shutdown: Option<Arc<AtomicBool>>,
) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("lifespan HTTP server control receiver went away".to_owned());
}
let builder =
modern::ServerBuilder::new("facade-http-lifespan", "1.0.0").tool(PublicHttpValue);
let builder = match startup {
Some(flag) => builder.on_startup(move || -> Result<(), std::io::Error> {
flag.store(true, Ordering::SeqCst);
Ok(())
}),
None => builder,
};
let builder = match shutdown {
Some(flag) => builder.on_shutdown(move || {
flag.store(true, Ordering::SeqCst);
}),
None => builder,
};
let server = builder.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("lifespan facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("lifespan facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("lifespan HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("lifespan facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup_guard = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup_guard.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("lifespan facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("lifespan facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup_guard.resume_thread_panic_if_finished();
panic!("lifespan facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup_guard.capture_server_cx();
let (server_cx, finished, join) = startup_guard.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_discovery_retains_instructions_and_peer_stays_bare() {
let cx = Cx::for_request();
let instructed = spawn_modern_instructions_http_server(true);
let bare = spawn_modern_instructions_http_server(false);
let instructed_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-instructions", "1.0.0")
.connect_http_with_cx(public_http_target(instructed.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the instructed HTTP server");
let bare_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-instructions-bare", "1.0.0")
.connect_http_with_cx(public_http_target(bare.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the bare HTTP server");
assert_eq!(
instructed_client
.server_discovery()
.instructions()
.map(fastmcp_rust::ServerInstructions::as_str),
Some(PUBLIC_HTTP_INSTRUCTIONS),
"live bind_http discovery must retain the configured instructions"
);
assert_eq!(
bare_client.server_discovery().instructions(),
None,
"changing only the missing instructions must keep the peer bare"
);
drop(instructed_client);
drop(bare_client);
instructed.shutdown();
bare.shutdown();
}
fn spawn_modern_hide_http_server() -> HttpServerFixture {
spawn_legacy_http_server("modern hide", || {
ServerBuilder::new("facade-http-hide", "1.0.0")
.protocol_policy(ProtocolPolicy::ModernOnly)
.expect("ModernOnly is available")
.tool(PublicHttpTouchTool)
.tool(PublicHttpHideTool)
.tool(PublicHttpShowTool)
.tool(PublicHttpFastTool)
.build()
})
}
fn spawn_legacy_hide_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy hide", || {
ServerBuilder::new("facade-http-legacy-hide", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpValue)
.tool(PublicHttpTouchTool)
.tool(PublicHttpHideTool)
.tool(PublicHttpShowTool)
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.build()
})
}
fn spawn_legacy_hide_catalog_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy hide catalog", || {
ServerBuilder::new("facade-http-legacy-hide-catalog", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpValue)
.tool(PublicHttpHideCatalogTool)
.tool(PublicHttpShowCatalogTool)
.tool(PublicHttpFastTool)
.prompt(PublicHttpCatalogPrompt)
.resource(PublicHttpWatchResource)
.build()
})
}
fn spawn_modern_as_proxy_leak_upstream() -> HttpServerFixture {
spawn_legacy_http_server("modern as_proxy leak upstream", || {
ServerBuilder::new("facade-http-as-proxy-leak-upstream", "1.0.0")
.protocol_policy(ProtocolPolicy::ModernOnly)
.expect("ModernOnly is available")
.mask_error_details(false)
.tool(PublicHttpFastTool)
.resource(PublicHttpLeakResource)
.build()
})
}
fn spawn_legacy_as_proxy_leak_upstream() -> HttpServerFixture {
spawn_legacy_http_server("legacy as_proxy leak upstream", || {
ServerBuilder::new("facade-http-legacy-as-proxy-leak-upstream", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.mask_error_details(false)
.tool(PublicHttpValue)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpLeakResource)
.build()
})
}
fn spawn_legacy_as_proxy_timeout_upstream() -> HttpServerFixture {
spawn_legacy_http_server("legacy as_proxy timeout upstream", || {
ServerBuilder::new("facade-http-legacy-as-proxy-timeout-upstream", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpValue)
.tool(PublicHttpSlowTool)
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.build()
})
}
fn spawn_modern_as_proxy_hold_upstream() -> HttpServerFixture {
spawn_legacy_http_server("modern as_proxy hold upstream", || {
ServerBuilder::new("facade-http-modern-as-proxy-hold-upstream", "1.0.0")
.protocol_policy(ProtocolPolicy::ModernOnly)
.expect("ModernOnly is available")
.tool(PublicHttpValue)
.tool(PublicHttpHoldTool)
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.build()
})
}
fn spawn_legacy_as_proxy_hold_upstream() -> HttpServerFixture {
spawn_legacy_http_server("legacy as_proxy hold upstream", || {
ServerBuilder::new("facade-http-legacy-as-proxy-hold-upstream", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpValue)
.tool(PublicHttpHoldTool)
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.build()
})
}
fn spawn_modern_identity_http_server(with_identity: bool) -> HttpServerFixture {
spawn_legacy_http_server("modern identity", move || {
let builder = ServerBuilder::new("facade-http-identity", "1.0.0")
.protocol_policy(ProtocolPolicy::ModernOnly)
.expect("ModernOnly is available")
.tool(PublicHttpFastTool);
let builder = if with_identity {
builder
.title("Identity Title")
.description("Identity description")
.website_url("https://example.test/fastmcp")
.icons(vec![
RawIcon::try_new("https://example.test/e2e-icon.png")
.expect("the identity icon source is an absolute URI"),
])
} else {
builder
};
builder.build()
})
}
#[test]
fn e2e_public_http_discovery_retains_implementation_identity_and_peer_stays_bare() {
let cx = Cx::for_request();
let identified = spawn_modern_identity_http_server(true);
let bare = spawn_modern_identity_http_server(false);
let identified_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-identity", "1.0.0")
.connect_http_with_cx(public_http_target(identified.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the identified HTTP server");
let bare_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-identity-bare", "1.0.0")
.connect_http_with_cx(public_http_target(bare.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the bare identity HTTP server");
let identified_discovery = identified_client.server_discovery();
let identified_impl = identified_discovery
.implementation()
.expect("configured modern discovery must retain Implementation identity");
assert_eq!(identified_impl.name, "facade-http-identity");
assert_eq!(identified_impl.version, "1.0.0");
assert_eq!(identified_impl.title.as_deref(), Some("Identity Title"));
assert_eq!(
identified_impl.description.as_deref(),
Some("Identity description")
);
assert_eq!(
identified_impl.website_url.as_ref().map(|uri| uri.as_str()),
Some("https://example.test/fastmcp")
);
assert_eq!(
identified_impl.icons.first().map(|icon| icon.src.as_str()),
Some("https://example.test/e2e-icon.png"),
"live bind_http discovery must retain the configured icon: {identified_impl:?}"
);
let bare_discovery = bare_client.server_discovery();
assert!(
bare_discovery.implementation().is_none(),
"changing only the missing identity extras must keep discovery name/version-only: {:?}",
bare_discovery.implementation()
);
assert_eq!(
bare_discovery
.server_info()
.map(|info| (info.name.as_str(), info.version.as_str())),
Some(("facade-http-identity", "1.0.0")),
"the bare peer must still advertise name and version"
);
drop(identified_client);
drop(bare_client);
identified.shutdown();
bare.shutdown();
}
fn spawn_modern_client_identity_http_server() -> HttpServerFixture {
spawn_legacy_http_server("modern client identity", || {
ServerBuilder::new("facade-http-client-identity", "1.0.0")
.protocol_policy(ProtocolPolicy::ModernOnly)
.expect("ModernOnly is available")
.tool(PublicHttpClientIdentity)
.build()
})
}
#[test]
fn e2e_public_http_client_implementation_is_visible_to_handler() {
let cx = Cx::for_request();
let server = spawn_modern_client_identity_http_server();
let mut identified_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-client-identity", "1.0.0")
.title("Client Title")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the identified ModernOnly public facade connects to the client-identity HTTP server");
let mut bare_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-client-identity-bare", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the bare ModernOnly public facade connects to the client-identity HTTP server");
let identified = runtime_block_on_bounded(
&cx,
identified_client.call_tool(&cx, PUBLIC_HTTP_CLIENT_IDENTITY_TOOL_NAME, json!({})),
)
.expect("identified modern HTTP tools/call must reach the client-identity handler");
assert!(
identified.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text == "name=e2e-public-http-client-identity|title=Client Title"
}
_ => false,
}),
"live bind_http must attach ClientBuilder title onto McpContext: {identified:?}"
);
let bare = runtime_block_on_bounded(
&cx,
bare_client.call_tool(&cx, PUBLIC_HTTP_CLIENT_IDENTITY_TOOL_NAME, json!({})),
)
.expect("bare modern HTTP tools/call must still reach the client-identity handler");
assert!(
bare.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text == "name=e2e-public-http-client-identity-bare|title=none"
}
_ => false,
}),
"changing only the missing client title must keep handler-visible extras bare: {bare:?}"
);
drop(identified_client);
drop(bare_client);
server.shutdown();
}
fn spawn_legacy_client_identity_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy client identity", || {
ServerBuilder::new("facade-http-legacy-client-identity", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpClientIdentity)
.build()
})
}
#[test]
fn e2e_public_http_legacy_client_info_is_visible_to_handler() {
let cx = Cx::for_request();
let server = spawn_legacy_client_identity_http_server();
let mut identified_client = runtime_block_on_bounded(
&cx,
legacy_2024::http_client_builder(
public_http_target(server.address(), "/sse"),
public_http_target(server.address(), "/messages"),
)
.expect("the exact-2024 identified HTTP endpoints form one public facade plan")
.client_info("e2e-public-http-legacy-client-identity", "1.0.0")
.connect_http_client_with_cx(&cx),
)
.expect("the identified LegacyOnly public facade connects to the client-identity HTTP server");
let mut other_client = runtime_block_on_bounded(
&cx,
legacy_2024::http_client_builder(
public_http_target(server.address(), "/sse"),
public_http_target(server.address(), "/messages"),
)
.expect("the exact-2024 other HTTP endpoints form one public facade plan")
.client_info("e2e-public-http-legacy-client-identity-other", "1.0.0")
.connect_http_client_with_cx(&cx),
)
.expect("the other LegacyOnly public facade connects to the client-identity HTTP server");
let identified = runtime_block_on_bounded(
&cx,
identified_client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_CLIENT_IDENTITY_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("identified exact-2024 HTTP tools/call must reach the client-identity handler");
assert_eq!(
legacy_http_tool_text(&identified).as_deref(),
Some("name=e2e-public-http-legacy-client-identity|title=none"),
"live exact-2024 bind_http must attach initialize clientInfo onto McpContext: {identified:?}"
);
let other = runtime_block_on_bounded(
&cx,
other_client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_CLIENT_IDENTITY_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("the other exact-2024 HTTP tools/call must still reach the client-identity handler");
assert_eq!(
legacy_http_tool_text(&other).as_deref(),
Some("name=e2e-public-http-legacy-client-identity-other|title=none"),
"changing only the initialize client name must change the handler-visible identity: {other:?}"
);
drop(identified_client);
drop(other_client);
server.shutdown();
}
fn spawn_legacy_instructions_http_server(with_instructions: bool) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("legacy instructions HTTP server control receiver went away".to_owned());
}
let builder = ServerBuilder::new("facade-http-legacy-instructions", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpValue);
let builder = if with_instructions {
builder.instructions(PUBLIC_HTTP_INSTRUCTIONS)
} else {
builder
};
let server = builder.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message =
format!("legacy instructions facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("legacy instructions facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("legacy instructions HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("legacy instructions facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("legacy instructions facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => {
panic!("legacy instructions facade HTTP server failed to start: {error}")
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("legacy instructions facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_legacy_initialize_retains_instructions_and_peer_stays_bare() {
let cx = Cx::for_request();
let instructed = spawn_legacy_instructions_http_server(true);
let bare = spawn_legacy_instructions_http_server(false);
let instructed_client = runtime_block_on_bounded(
&cx,
legacy_2024::http_client_builder(
public_http_target(instructed.address(), "/sse"),
public_http_target(instructed.address(), "/messages"),
)
.expect("the exact-2024 instructed HTTP endpoints form one public facade plan")
.client_info("e2e-public-http-legacy-instructions", "1.0.0")
.connect_http_client_with_cx(&cx),
)
.expect("the LegacyOnly public facade connects to the instructed HTTP server");
let bare_client = runtime_block_on_bounded(
&cx,
legacy_2024::http_client_builder(
public_http_target(bare.address(), "/sse"),
public_http_target(bare.address(), "/messages"),
)
.expect("the exact-2024 bare HTTP endpoints form one public facade plan")
.client_info("e2e-public-http-legacy-instructions-bare", "1.0.0")
.connect_http_client_with_cx(&cx),
)
.expect("the LegacyOnly public facade connects to the bare HTTP server");
assert_eq!(
instructed_client.instructions(),
Some(PUBLIC_HTTP_INSTRUCTIONS),
"live exact-2024 bind_http initialize must retain the configured instructions"
);
assert_eq!(
bare_client.instructions(),
None,
"changing only the missing instructions must keep the peer bare"
);
drop(instructed_client);
drop(bare_client);
instructed.shutdown();
bare.shutdown();
}
fn spawn_legacy_catalog_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("legacy catalog HTTP server control receiver went away".to_owned());
}
let server = ServerBuilder::new("facade-http-legacy-catalog", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpValue)
.tool(PublicHttpTouchTool)
.tool(PublicHttpHideTool)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("legacy catalog facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("legacy catalog facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("legacy catalog HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("legacy catalog facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("legacy catalog facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("legacy catalog facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("legacy catalog facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
fn take_legacy_http_notifications_until(
client: &mut legacy_2024::HttpClient,
deadline: Instant,
) -> Vec<legacy_2024::ServerNotification> {
let mut notifications = Vec::new();
loop {
match client.take_server_notification() {
Ok(Some(notification)) => notifications.push(notification),
Ok(None) => {
if Instant::now() >= deadline {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("exact-2024 HTTP SSE notification must decode: {error}"),
}
}
notifications
}
#[test]
fn e2e_public_http_legacy_tools_list_changed_is_retained_on_sse() {
let cx = Cx::for_request();
let server = spawn_legacy_catalog_http_server();
let mut client = runtime_block_on_bounded_named(
&cx,
"exact-2024 catalog HTTP connect",
legacy_2024::http_client_builder(
public_http_target(server.address(), "/sse"),
public_http_target(server.address(), "/messages"),
)
.expect("the exact-2024 catalog HTTP endpoints form one public facade plan")
.client_info("e2e-public-http-legacy-list-changed", "1.0.0")
.connect_http_client_with_cx(&cx),
)
.expect("the LegacyOnly public facade connects to the catalog HTTP server");
let quiet = runtime_block_on_bounded_named(
&cx,
"exact-2024 non-mutating tools/call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_TOOL_NAME.to_owned(),
arguments: Some(json!({ "value": "quiet" })),
meta: None,
},
),
)
.expect("a non-mutating exact-2024 tools/call must complete");
assert!(
serde_json::to_value(&quiet)
.ok()
.and_then(|value| value["content"][0]["text"].as_str().map(str::to_owned))
.as_deref()
== Some("tool:quiet"),
"the non-mutating peer must still return its text: {quiet:?}"
);
let silent = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ToolsListChanged
)),
"changing only the missing catalog mutation must keep tools/list_changed silent: {silent:?}"
);
let hidden = runtime_block_on_bounded_named(
&cx,
"exact-2024 hide tools/call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_HIDE_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("disabling a peer tool must complete");
assert!(
serde_json::to_value(&hidden)
.ok()
.and_then(|value| value["content"][0]["text"].as_str().map(str::to_owned))
.as_deref()
== Some("hidden"),
"exact-2024 SSE session state must let disable_tool publish list_changed: {hidden:?}"
);
let changed =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
changed.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ToolsListChanged
)),
"live exact-2024 HTTP SSE must retain notifications/tools/list_changed: {changed:?}"
);
drop(client);
server.shutdown();
}
fn spawn_legacy_progress_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("legacy progress HTTP server control receiver went away".to_owned());
}
let server = ServerBuilder::new("facade-http-legacy-progress", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpProgressTool)
.resource(PublicHttpProgressResource)
.prompt(PublicHttpProgressPrompt)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message =
format!("legacy progress facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("legacy progress facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("legacy progress HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("legacy progress facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("legacy progress facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => {
panic!("legacy progress facade HTTP server failed to start: {error}")
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("legacy progress facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
fn legacy_http_progress_meta(
marker: legacy_2024::ProgressMarker,
) -> Option<legacy_2024::RequestMeta> {
Some(legacy_2024::RequestMeta {
progress_marker: Some(marker),
})
}
#[test]
fn e2e_public_http_legacy_progress_marker_is_retained_on_sse() {
let cx = Cx::for_request();
let server = spawn_legacy_progress_http_server();
let mut client = runtime_block_on_bounded_named(
&cx,
"exact-2024 progress HTTP connect",
legacy_2024::http_client_builder(
public_http_target(server.address(), "/sse"),
public_http_target(server.address(), "/messages"),
)
.expect("the exact-2024 progress HTTP endpoints form one public facade plan")
.client_info("e2e-public-http-legacy-progress", "1.0.0")
.connect_http_client_with_cx(&cx),
)
.expect("the LegacyOnly public facade connects to the progress HTTP server");
runtime_block_on_bounded_named(
&cx,
"exact-2024 progress tools/call without token",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_PROGRESS_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("a tools/call without a progress token still completes");
let silent = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"without a progressToken the exact-2024 HTTP tool must stay silent: {silent:?}"
);
let marker = legacy_2024::ProgressMarker::from("http-legacy-progress");
runtime_block_on_bounded_named(
&cx,
"exact-2024 progress tools/call with token",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_PROGRESS_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: legacy_http_progress_meta(marker.clone()),
},
),
)
.expect("a progressToken must not prevent the exact-2024 HTTP tool from completing");
let progress =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == marker
&& params.message.as_deref() == Some("halfway")
)),
"live exact-2024 HTTP SSE must retain notifications/progress after a progressToken: {progress:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 progress resources/read without token",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_PROGRESS_RESOURCE_URI.to_owned(),
meta: None,
},
),
)
.expect("a resources/read without a progress token still completes");
let silent_resource = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent_resource.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"without a progressToken the exact-2024 HTTP resource must stay silent: {silent_resource:?}"
);
let resource_marker = legacy_2024::ProgressMarker::from("http-legacy-resource-progress");
runtime_block_on_bounded_named(
&cx,
"exact-2024 progress resources/read with token",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_PROGRESS_RESOURCE_URI.to_owned(),
meta: legacy_http_progress_meta(resource_marker.clone()),
},
),
)
.expect("a progressToken must not prevent the exact-2024 HTTP resource from completing");
let resource_progress =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
resource_progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == resource_marker
&& params.message.as_deref() == Some("resource-halfway")
)),
"live exact-2024 HTTP SSE must retain resource notifications/progress after a progressToken: {resource_progress:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 progress prompts/get without token",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: PUBLIC_HTTP_PROGRESS_PROMPT_NAME.to_owned(),
arguments: None,
meta: None,
},
),
)
.expect("a prompts/get without a progress token still completes");
let silent_prompt = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent_prompt.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"without a progressToken the exact-2024 HTTP prompt must stay silent: {silent_prompt:?}"
);
let prompt_marker = legacy_2024::ProgressMarker::from("http-legacy-prompt-progress");
runtime_block_on_bounded_named(
&cx,
"exact-2024 progress prompts/get with token",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: PUBLIC_HTTP_PROGRESS_PROMPT_NAME.to_owned(),
arguments: None,
meta: legacy_http_progress_meta(prompt_marker.clone()),
},
),
)
.expect("a progressToken must not prevent the exact-2024 HTTP prompt from completing");
let prompt_progress =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
prompt_progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == prompt_marker
&& params.message.as_deref() == Some("prompt-halfway")
)),
"live exact-2024 HTTP SSE must retain prompt notifications/progress after a progressToken: {prompt_progress:?}"
);
drop(client);
server.shutdown();
}
fn spawn_legacy_log_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("legacy log HTTP server control receiver went away".to_owned());
}
let server = ServerBuilder::new("facade-http-legacy-log", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpLogTool)
.resource(PublicHttpLogResource)
.prompt(PublicHttpLogPrompt)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("legacy log facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("legacy log facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("legacy log HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("legacy log facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("legacy log facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("legacy log facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("legacy log facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
fn legacy_http_log_message_is(
notification: &legacy_2024::ServerNotification,
expected: &str,
) -> bool {
matches!(
notification,
legacy_2024::ServerNotification::Message(message)
if message.level == legacy_2024::LogLevel::Info
&& message.data == json!(expected)
)
}
#[test]
fn e2e_public_http_legacy_ctx_info_is_retained_on_sse() {
let cx = Cx::for_request();
let server = spawn_legacy_log_http_server();
let mut client = runtime_block_on_bounded_named(
&cx,
"exact-2024 log HTTP connect",
legacy_2024::http_client_builder(
public_http_target(server.address(), "/sse"),
public_http_target(server.address(), "/messages"),
)
.expect("the exact-2024 log HTTP endpoints form one public facade plan")
.client_info("e2e-public-http-legacy-log", "1.0.0")
.connect_http_client_with_cx(&cx),
)
.expect("the LegacyOnly public facade connects to the log HTTP server");
runtime_block_on_bounded_named(
&cx,
"exact-2024 tools/call before setLevel",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_LOG_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("ctx.info must not prevent the exact-2024 HTTP tool from completing");
let silent = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent.iter().any(|notification| legacy_http_log_message_is(
notification,
PUBLIC_HTTP_HANDLER_LOG_TEXT
)),
"omitting only logging/setLevel must keep exact-2024 HTTP ctx.info silent: {silent:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 logging/setLevel Info",
client.set_log_level(&cx, legacy_2024::LogLevel::Info),
)
.expect("exact-2024 HTTP logging/setLevel must be admitted");
runtime_block_on_bounded_named(
&cx,
"exact-2024 tools/call after setLevel Info",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_LOG_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("ctx.info must not prevent the exact-2024 HTTP tool from completing");
let info =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
info.iter().any(|notification| legacy_http_log_message_is(
notification,
PUBLIC_HTTP_HANDLER_LOG_TEXT
)),
"live exact-2024 HTTP SSE must retain ctx.info after set_log_level(Info): {info:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 resources/read after setLevel Info",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_LOG_RESOURCE_URI.to_owned(),
meta: None,
},
),
)
.expect("ctx.info must not prevent the exact-2024 HTTP resource from completing");
let resource_info =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
resource_info
.iter()
.any(|notification| legacy_http_log_message_is(
notification,
PUBLIC_HTTP_RESOURCE_LOG_TEXT
)),
"live exact-2024 HTTP SSE must retain resource ctx.info after set_log_level(Info): {resource_info:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 prompts/get after setLevel Info",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: PUBLIC_HTTP_LOG_PROMPT_NAME.to_owned(),
arguments: None,
meta: None,
},
),
)
.expect("ctx.info must not prevent the exact-2024 HTTP prompt from completing");
let prompt_info =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
prompt_info
.iter()
.any(|notification| legacy_http_log_message_is(
notification,
PUBLIC_HTTP_PROMPT_LOG_TEXT
)),
"live exact-2024 HTTP SSE must retain prompt ctx.info after set_log_level(Info): {prompt_info:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 logging/setLevel Emergency",
client.set_log_level(&cx, legacy_2024::LogLevel::Emergency),
)
.expect("raising the exact-2024 HTTP log floor must be admitted");
runtime_block_on_bounded_named(
&cx,
"exact-2024 tools/call after setLevel Emergency",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_LOG_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("raising the log floor must not prevent the exact-2024 HTTP tool from completing");
let emergency = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!emergency
.iter()
.any(|notification| legacy_http_log_message_is(
notification,
PUBLIC_HTTP_HANDLER_LOG_TEXT
)),
"raising only the logLevel floor must suppress exact-2024 HTTP ctx.info: {emergency:?}"
);
drop(client);
server.shutdown();
}
fn spawn_legacy_subscription_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("legacy subscription HTTP server control receiver went away".to_owned());
}
let server = ServerBuilder::new("facade-http-legacy-subscription", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.resource(PublicHttpWatchResource)
.prompt(PublicHttpCatalogPrompt)
.tool(PublicHttpTouchTool)
.tool(PublicHttpHideCatalogTool)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message =
format!("legacy subscription facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("legacy subscription facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("legacy subscription HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("legacy subscription facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("legacy subscription facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => {
panic!("legacy subscription facade HTTP server failed to start: {error}")
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("legacy subscription facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
fn connect_legacy_subscription_http_client(
cx: &Cx,
address: SocketAddr,
name: &str,
) -> legacy_2024::HttpClient {
runtime_block_on_bounded_named(
cx,
"exact-2024 subscription HTTP connect",
legacy_2024::http_client_builder(
public_http_target(address, "/sse"),
public_http_target(address, "/messages"),
)
.expect("the exact-2024 subscription HTTP endpoints form one public facade plan")
.client_info(name, "1.0.0")
.connect_http_client_with_cx(cx),
)
.expect("the LegacyOnly public facade connects to the subscription HTTP server")
}
fn legacy_http_tool_text(result: &legacy_2024::CallToolResult) -> Option<String> {
serde_json::to_value(result)
.ok()
.and_then(|value| value["content"][0]["text"].as_str().map(str::to_owned))
}
fn legacy_disabled_tool_observable<E: std::fmt::Debug>(
result: Result<legacy_2024::CallToolResult, E>,
) -> String {
match result {
Ok(result) => {
assert!(
result.is_error,
"hide must turn a later tools/call into a tool-level error: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
}
}
#[test]
fn e2e_public_http_legacy_resource_updated_is_retained_on_sse() {
let cx = Cx::for_request();
let server = spawn_legacy_subscription_http_server();
let mut client = connect_legacy_subscription_http_client(
&cx,
server.address(),
"e2e-public-http-legacy-updated",
);
let silent_touch = runtime_block_on_bounded_named(
&cx,
"exact-2024 resources/updated touch without subscribe",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_TOUCH_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("touching an unsubscribed resource must complete");
assert_eq!(
legacy_http_tool_text(&silent_touch).as_deref(),
Some("silent"),
"without resources/subscribe the handler must not claim updated delivery: {silent_touch:?}"
);
let silent = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ResourceUpdated(_)
)),
"omitting only resources/subscribe must keep resources/updated silent: {silent:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 resources/subscribe",
client.subscribe_resource(
&cx,
legacy_2024::SubscribeResourceParams {
uri: PUBLIC_HTTP_WATCH_RESOURCE_URI.to_owned(),
},
),
)
.expect("exact-2024 HTTP resources/subscribe must be admitted");
let touched = runtime_block_on_bounded_named(
&cx,
"exact-2024 resources/updated touch after subscribe",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_TOUCH_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("touching a subscribed resource must complete");
assert_eq!(
legacy_http_tool_text(&touched).as_deref(),
Some("notified"),
"resources/subscribe must count as notify_resource_updated delivery: {touched:?}"
);
let updated =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
updated.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ResourceUpdated(params)
if params.uri == PUBLIC_HTTP_WATCH_RESOURCE_URI
)),
"live exact-2024 HTTP SSE must retain notifications/resources/updated: {updated:?}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_resource_unsubscribe_stops_updated_notifications() {
let cx = Cx::for_request();
let server = spawn_legacy_subscription_http_server();
let mut client = connect_legacy_subscription_http_client(
&cx,
server.address(),
"e2e-public-http-legacy-unsubscribe",
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 resources/subscribe before unsubscribe",
client.subscribe_resource(
&cx,
legacy_2024::SubscribeResourceParams {
uri: PUBLIC_HTTP_WATCH_RESOURCE_URI.to_owned(),
},
),
)
.expect("exact-2024 HTTP resources/subscribe must be admitted");
let subscribed = runtime_block_on_bounded_named(
&cx,
"exact-2024 resources/updated touch after subscribe",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_TOUCH_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("touching a subscribed resource must complete");
assert_eq!(
legacy_http_tool_text(&subscribed).as_deref(),
Some("notified"),
"resources/subscribe must count as notify_resource_updated delivery: {subscribed:?}"
);
let updated =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
updated.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ResourceUpdated(params)
if params.uri == PUBLIC_HTTP_WATCH_RESOURCE_URI
)),
"the subscribed exact-2024 HTTP SSE session must retain resources/updated: {updated:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 resources/unsubscribe",
client.unsubscribe_resource(
&cx,
legacy_2024::UnsubscribeResourceParams {
uri: PUBLIC_HTTP_WATCH_RESOURCE_URI.to_owned(),
},
),
)
.expect("exact-2024 HTTP resources/unsubscribe must be admitted");
let silent_touch = runtime_block_on_bounded_named(
&cx,
"exact-2024 resources/updated touch after unsubscribe",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_TOUCH_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("touching an unsubscribed resource must complete");
assert_eq!(
legacy_http_tool_text(&silent_touch).as_deref(),
Some("silent"),
"resources/unsubscribe must stop notify_resource_updated delivery: {silent_touch:?}"
);
let silent = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ResourceUpdated(_)
)),
"changing only resources/unsubscribe must keep later resources/updated silent: {silent:?}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_resource_and_prompt_list_changed_are_retained_on_sse() {
let cx = Cx::for_request();
let server = spawn_legacy_subscription_http_server();
let mut client = connect_legacy_subscription_http_client(
&cx,
server.address(),
"e2e-public-http-legacy-catalog",
);
let quiet = runtime_block_on_bounded_named(
&cx,
"exact-2024 catalog touch without mutation",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_TOUCH_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("a non-mutating exact-2024 tools/call must complete");
assert_eq!(
legacy_http_tool_text(&quiet).as_deref(),
Some("silent"),
"the unsubscribed touch peer must stay non-mutating: {quiet:?}"
);
let silent = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ResourcesListChanged
| legacy_2024::ServerNotification::PromptsListChanged
)),
"changing only the missing catalog mutation must keep resource/prompt list_changed silent: {silent:?}"
);
let hidden = runtime_block_on_bounded_named(
&cx,
"exact-2024 hide catalog tools/call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_HIDE_CATALOG_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("disabling a peer resource and prompt must complete");
assert_eq!(
legacy_http_tool_text(&hidden).as_deref(),
Some("hidden"),
"exact-2024 SSE session state must let disable_resource/disable_prompt publish: {hidden:?}"
);
let changed =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
changed.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ResourcesListChanged
)),
"live exact-2024 HTTP SSE must retain notifications/resources/list_changed: {changed:?}"
);
assert!(
changed.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::PromptsListChanged
)),
"live exact-2024 HTTP SSE must retain notifications/prompts/list_changed: {changed:?}"
);
drop(client);
server.shutdown();
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn spawn_legacy_filesystem_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let root = std::env::temp_dir().join(format!(
"fastmcp-public-http-legacy-fs-e2e-{}",
std::process::id()
));
std::fs::create_dir_all(&root).expect("the legacy filesystem e2e root is created");
std::fs::write(
root.join(PUBLIC_HTTP_FS_FILE_NAME),
PUBLIC_HTTP_FS_FILE_TEXT,
)
.expect("the legacy filesystem e2e file is written");
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("legacy filesystem HTTP server control receiver went away".to_owned());
}
let handler = providers::FilesystemProvider::new(&root)
.with_prefix(PUBLIC_HTTP_FS_PREFIX)
.with_exclude(&[])
.build()
.map_err(|error| format!("FilesystemProvider::build failed: {error}"))?;
let server = ServerBuilder::new("facade-http-legacy-filesystem", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.resource(handler)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message =
format!("legacy filesystem facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("legacy filesystem facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("legacy filesystem HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("legacy filesystem facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("legacy filesystem facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => {
panic!("legacy filesystem facade HTTP server failed to start: {error}")
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("legacy filesystem facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn e2e_public_http_legacy_filesystem_provider_lists_and_reads_live_file() {
let cx = Cx::for_request();
let server = spawn_legacy_filesystem_http_server();
let mut client = runtime_block_on_bounded_named(
&cx,
"exact-2024 filesystem HTTP connect",
legacy_2024::http_client_builder(
public_http_target(server.address(), "/sse"),
public_http_target(server.address(), "/messages"),
)
.expect("the exact-2024 filesystem HTTP endpoints form one public facade plan")
.client_info("e2e-public-http-legacy-fs", "1.0.0")
.connect_http_client_with_cx(&cx),
)
.expect("the LegacyOnly public facade connects to the filesystem HTTP server");
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 resources/templates/list",
client.list_resource_templates(&cx, legacy_2024::ListResourceTemplatesParams::default()),
)
.expect("live exact-2024 bind_http must list the FilesystemProvider template");
assert!(
listed
.resource_templates
.iter()
.any(|template| template.uri_template == PUBLIC_HTTP_FS_TEMPLATE
&& template.name == PUBLIC_HTTP_FS_PREFIX),
"FilesystemProvider must advertise its reversible file template: {:?}",
listed.resource_templates
);
let unmatched = runtime_block_on_bounded_named(
&cx,
"exact-2024 unmatched filesystem resources/read",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "file:///other/note.txt".to_owned(),
meta: None,
},
),
)
.expect_err("changing only the prefix the template cannot bind must refuse before dispatch");
assert!(
matches!(
unmatched,
legacy_2024::HttpClientError::CoreResult(ref error)
if error.code == McpErrorCode::ResourceNotFound
),
"an unmatched filesystem URI must stay ResourceNotFound on exact-2024: {unmatched:?}"
);
let file = runtime_block_on_bounded_named(
&cx,
"exact-2024 matched filesystem resources/read",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_FS_FILE_URI.to_owned(),
meta: None,
},
),
)
.expect("resources/read must expand the live file URI through the filesystem handler");
let file_text = serde_json::to_value(&file)
.ok()
.and_then(|value| value["contents"][0]["text"].as_str().map(str::to_owned));
assert_eq!(
file_text.as_deref(),
Some(PUBLIC_HTTP_FS_FILE_TEXT),
"the live exact-2024 filesystem read must retain the file bytes: {file:?}"
);
drop(client);
server.shutdown();
}
const PUBLIC_HTTP_SAMPLE_TOOL_NAME: &str = "public-http-e2e-sample";
const PUBLIC_HTTP_SAMPLED_TEXT: &str = "sampled-legacy-http";
/// Live exact-2024 HTTP tool that issues one reverse `sampling/createMessage`.
struct PublicHttpSampleTool;
impl ToolHandler for PublicHttpSampleTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_SAMPLE_TOOL_NAME.to_owned(),
description: Some("Proves live exact-2024 HTTP sampling reverse JSON-RPC".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, _ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
Err(McpError::internal_error(
"public-http-e2e-sample is request-owned async",
))
}
fn call_async<'a>(
&'a self,
ctx: &'a McpContext,
_arguments: serde_json::Value,
) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
Box::pin(async move {
match ctx.sample("echo", 16).await {
Ok(response) => Outcome::Ok(vec![Content::text(response.text)]),
Err(error) => Outcome::Err(error),
}
})
}
}
fn spawn_legacy_sample_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("legacy sample HTTP server control receiver went away".to_owned());
}
let server = ServerBuilder::new("facade-http-legacy-sample", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpSampleTool)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("legacy sample facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("legacy sample facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("legacy sample HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("legacy sample facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("legacy sample facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("legacy sample facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("legacy sample facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
fn spawn_legacy_complete_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let completion_counters = Arc::clone(&handler_calls);
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("legacy complete HTTP server control receiver went away".to_owned());
}
let server = ServerBuilder::new("facade-http-legacy-complete", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.prompt(PublicHttpCatalogPrompt)
.legacy_completion_handler(CountingPublicHttpCompletion {
counters: completion_counters,
})
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message =
format!("legacy complete facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("legacy complete facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("legacy complete HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("legacy complete facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("legacy complete facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("legacy complete facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("legacy complete facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
fn connect_legacy_http_client(cx: &Cx, address: SocketAddr, name: &str) -> legacy_2024::HttpClient {
runtime_block_on_bounded_named(
cx,
"exact-2024 HTTP connect",
legacy_2024::http_client_builder(
public_http_target(address, "/sse"),
public_http_target(address, "/messages"),
)
.expect("the exact-2024 HTTP endpoints form one public facade plan")
.client_info(name, "1.0.0")
.connect_http_client_with_cx(cx),
)
.expect("the LegacyOnly public facade connects")
}
#[test]
fn e2e_public_http_legacy_roots_list_changed_is_admitted_and_unadvertised_peer_is_refused() {
let cx = Cx::for_request();
let advertised = spawn_legacy_instructions_http_server(false);
let omitted = spawn_legacy_instructions_http_server(false);
let mut admitted = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP roots-change connect",
legacy_2024::http_client_builder(
public_http_target(advertised.address(), "/sse"),
public_http_target(advertised.address(), "/messages"),
)
.expect("the exact-2024 HTTP endpoints form one public facade plan")
.client_info("e2e-public-http-legacy-roots-changed", "1.0.0")
.capabilities(ClientCapabilities {
roots: Some(legacy_2024::RootsCapability { list_changed: true }),
..ClientCapabilities::default()
})
.reverse_request_handlers(
legacy_2024::LegacyReverseRequestHandlers::new().with_roots_list(
|_cx, _cancel, _params| {
Box::pin(async { Ok(legacy_2024::ListRootsResult::new(Vec::new())) })
},
),
)
.connect_http_client_with_cx(&cx),
)
.expect("an advertised roots.listChanged client connects");
runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP roots/list_changed",
admitted.roots_list_changed(&cx),
)
.expect("live exact-2024 HTTP must admit notifications/roots/list_changed when advertised");
runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP ping after roots/list_changed",
admitted.ping(&cx),
)
.expect("the same session must still admit ping after roots/list_changed");
let mut refused = connect_legacy_http_client(
&cx,
omitted.address(),
"e2e-public-http-legacy-roots-changed-bare",
);
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP unadvertised roots/list_changed",
refused.roots_list_changed(&cx),
)
.expect_err("changing only the missing roots.listChanged advertisement must refuse");
let missing = format!("{missing:?}");
assert!(
missing.contains("roots.listChanged") || missing.contains("InvalidRequest"),
"the unadvertised refusal must keep the capability gate: {missing}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP ping after unadvertised roots/list_changed",
refused.ping(&cx),
)
.expect("changing only the missing advertisement must leave ping admitted");
drop(admitted);
drop(refused);
advertised.shutdown();
omitted.shutdown();
}
#[test]
fn e2e_public_http_legacy_ping_is_admitted_and_missing_tool_stays_refused() {
let cx = Cx::for_request();
let server = spawn_legacy_instructions_http_server(false);
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-ping");
runtime_block_on_bounded_named(&cx, "exact-2024 HTTP ping", client.ping(&cx))
.expect("live exact-2024 HTTP SSE must admit ping");
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 missing tools/call after ping",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "public-http-e2e-missing".to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect_err("changing only the missing tool must keep the session refused");
assert!(
matches!(
missing,
legacy_2024::HttpClientError::CoreResult(ref error)
if error.code == McpErrorCode::MethodNotFound
|| error.code == McpErrorCode::InvalidParams
|| error.code == McpErrorCode::InvalidRequest
),
"a missing exact-2024 HTTP tool must stay a handler-visible refusal: {missing:?}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_complete_is_retained_and_peer_without_handler_is_refused() {
let cx = Cx::for_request();
let advertised = spawn_legacy_complete_http_server();
let omitted = spawn_legacy_instructions_http_server(false);
let mut advertised_client =
connect_legacy_http_client(&cx, advertised.address(), "e2e-public-http-legacy-complete");
let mut omitted_client = connect_legacy_http_client(
&cx,
omitted.address(),
"e2e-public-http-legacy-complete-omitted",
);
assert!(
advertised_client
.server_capabilities()
.completions
.is_some(),
"installing a legacy completion handler must advertise completions: {:?}",
advertised_client.server_capabilities()
);
assert!(
omitted_client.server_capabilities().completions.is_none(),
"changing only the missing completion handler must omit completions: {:?}",
omitted_client.server_capabilities()
);
let params = legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt {
name: PUBLIC_HTTP_CATALOG_PROMPT_NAME.to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
meta: None,
};
let completed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP completion/complete",
advertised_client.complete(&cx, params.clone()),
)
.expect("live exact-2024 HTTP SSE must complete when completions are advertised");
assert_eq!(
completed.completion.values,
vec![PUBLIC_HTTP_COMPLETION_VALUE.to_owned()],
"the advertised exact-2024 completion provider must retain its values: {completed:?}"
);
let silent = take_legacy_http_notifications_until(
&mut advertised_client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"without a progressToken the exact-2024 HTTP completion must stay silent: {silent:?}"
);
let marker = legacy_2024::ProgressMarker::from("http-legacy-completion-progress");
let progressed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP completion/complete with token",
advertised_client.complete_with_progress_marker(&cx, params.clone(), marker.clone()),
)
.expect("a progressToken must not prevent the exact-2024 HTTP completion from completing");
assert_eq!(
progressed.completion.values,
vec![PUBLIC_HTTP_COMPLETION_VALUE.to_owned()],
"the progress-marked exact-2024 completion must retain its values: {progressed:?}"
);
let progress = take_legacy_http_notifications_until(
&mut advertised_client,
Instant::now() + Duration::from_secs(1),
);
assert!(
progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == marker
&& params.message.as_deref() == Some("completion-legacy-halfway")
)),
"live exact-2024 HTTP SSE must retain completion notifications/progress after a progressToken: {progress:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP complete without handler",
omitted_client.complete(&cx, params),
)
.expect_err("changing only the missing completion handler must refuse complete");
drop(advertised_client);
drop(omitted_client);
advertised.shutdown();
omitted.shutdown();
}
#[test]
fn e2e_public_http_legacy_sampling_callback_reaches_context() {
let cx = Cx::for_request();
let server = spawn_legacy_sample_http_server();
let callback_calls = Arc::new(AtomicUsize::new(0));
let mut client = runtime_block_on_bounded_named(
&cx,
"exact-2024 sampling HTTP connect",
legacy_2024::http_client_builder(
public_http_target(server.address(), "/sse"),
public_http_target(server.address(), "/messages"),
)
.expect("the exact-2024 sampling HTTP endpoints form one public facade plan")
.client_info("e2e-public-http-legacy-sample", "1.0.0")
.capabilities(ClientCapabilities {
sampling: Some(Default::default()),
elicitation: None,
roots: None,
})
.reverse_request_handlers(
legacy_2024::LegacyReverseRequestHandlers::new().with_sampling_create_message({
let callback_calls = Arc::clone(&callback_calls);
move |_cx, _cancel, _params| {
callback_calls.fetch_add(1, Ordering::Relaxed);
Box::pin(async {
Ok(legacy_2024::LegacyCreateMessageResult::text(
PUBLIC_HTTP_SAMPLED_TEXT,
"e2e-legacy-http-model",
))
})
}
}),
)
.connect_http_client_with_cx(&cx),
)
.expect("the sampling callback is configured before exact-2024 HTTP initialization");
let sampled = runtime_block_on_bounded_named(
&cx,
"exact-2024 sampling tools/call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_SAMPLE_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("the sealed exact-2024 HTTP facade must service sampling/createMessage before its typed tool result");
assert_eq!(
legacy_http_tool_text(&sampled).as_deref(),
Some(PUBLIC_HTTP_SAMPLED_TEXT),
"live exact-2024 HTTP SSE sampling must retain the callback text: {sampled:?}"
);
assert_eq!(
callback_calls.load(Ordering::Relaxed),
1,
"the tool's context authority issues exactly one sampling/createMessage callback"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_sampling_without_capability_has_no_callback_authority() {
let cx = Cx::for_request();
let server = spawn_legacy_sample_http_server();
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-sample-bare");
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 sampling tools/call without capability",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_SAMPLE_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("missing sampling authority remains a typed exact-2024 tool result");
let text = legacy_http_tool_text(&missing).unwrap_or_default();
assert!(
missing.is_error || text.contains("does not support sampling capability"),
"omitting only the sampling capability must fail closed: {missing:?}"
);
assert_ne!(
text.as_str(),
PUBLIC_HTTP_SAMPLED_TEXT,
"a bare peer must not invent a sampling callback result: {missing:?}"
);
drop(client);
server.shutdown();
}
const PUBLIC_HTTP_LEGACY_ROOTS_TOOL_NAME: &str = "public-http-e2e-legacy-roots";
const PUBLIC_HTTP_LEGACY_ROOT_URI: &str = "file:///workspace";
/// Live exact-2024 HTTP tool that issues one reverse `roots/list`.
struct PublicHttpLegacyRootsTool;
impl ToolHandler for PublicHttpLegacyRootsTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_LEGACY_ROOTS_TOOL_NAME.to_owned(),
description: Some("Proves live exact-2024 HTTP roots/list reverse JSON-RPC".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, _ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
Err(McpError::internal_error(
"public-http-e2e-legacy-roots is request-owned async",
))
}
fn call_async<'a>(
&'a self,
ctx: &'a McpContext,
_arguments: serde_json::Value,
) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
Box::pin(async move {
match ctx.list_roots().await {
Ok(roots) => Outcome::Ok(vec![Content::text(
roots
.first()
.map(|root| root.uri.clone())
.unwrap_or_else(|| "<no client roots>".to_owned()),
)]),
Err(error) => Outcome::Err(error),
}
})
}
}
fn spawn_legacy_roots_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("legacy roots HTTP server control receiver went away".to_owned());
}
let server = ServerBuilder::new("facade-http-legacy-roots", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpLegacyRootsTool)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("legacy roots facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("legacy roots facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("legacy roots HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("legacy roots facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("legacy roots facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("legacy roots facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("legacy roots facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_legacy_roots_callback_reaches_context() {
let cx = Cx::for_request();
let server = spawn_legacy_roots_http_server();
let callback_calls = Arc::new(AtomicUsize::new(0));
let mut client = runtime_block_on_bounded_named(
&cx,
"exact-2024 roots HTTP connect",
legacy_2024::http_client_builder(
public_http_target(server.address(), "/sse"),
public_http_target(server.address(), "/messages"),
)
.expect("the exact-2024 roots HTTP endpoints form one public facade plan")
.client_info("e2e-public-http-legacy-roots", "1.0.0")
.capabilities(ClientCapabilities {
sampling: None,
elicitation: None,
roots: Some(Default::default()),
})
.reverse_request_handlers(
legacy_2024::LegacyReverseRequestHandlers::new().with_roots_list({
let callback_calls = Arc::clone(&callback_calls);
move |_cx, _cancel, _params| {
callback_calls.fetch_add(1, Ordering::Relaxed);
Box::pin(async {
Ok(legacy_2024::ListRootsResult::new(vec![
legacy_2024::Root::with_name(PUBLIC_HTTP_LEGACY_ROOT_URI, "workspace"),
]))
})
}
}),
)
.connect_http_client_with_cx(&cx),
)
.expect("the roots callback is configured before exact-2024 HTTP initialization");
let rooted = runtime_block_on_bounded_named(
&cx,
"exact-2024 roots tools/call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_LEGACY_ROOTS_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect(
"the sealed exact-2024 HTTP facade must service roots/list before its typed tool result",
);
assert_eq!(
legacy_http_tool_text(&rooted).as_deref(),
Some(PUBLIC_HTTP_LEGACY_ROOT_URI),
"live exact-2024 HTTP SSE roots must retain the callback URI: {rooted:?}"
);
assert_eq!(
callback_calls.load(Ordering::Relaxed),
1,
"the tool's context authority issues exactly one roots/list callback"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_roots_without_capability_has_no_callback_authority() {
let cx = Cx::for_request();
let server = spawn_legacy_roots_http_server();
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-roots-bare");
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 roots tools/call without capability",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_LEGACY_ROOTS_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("missing roots authority remains a typed exact-2024 tool result");
let text = legacy_http_tool_text(&missing).unwrap_or_default();
assert!(
missing.is_error || text.contains("does not support roots capability"),
"omitting only the roots capability must fail closed: {missing:?}"
);
assert_ne!(
text.as_str(),
PUBLIC_HTTP_LEGACY_ROOT_URI,
"a bare peer must not invent a roots callback result: {missing:?}"
);
drop(client);
server.shutdown();
}
fn spawn_legacy_http_server(
label: &'static str,
build: impl FnOnce() -> fastmcp_server::Server + Send + 'static,
) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err(format!("{label} HTTP server control receiver went away"));
}
let bound = match build().bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("{label} facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!("{label} facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err(format!("{label} HTTP server startup receiver went away"));
}
bound.serve(&cx).await.map_err(|error| {
format!("{label} facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("{label} facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("{label} facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("{label} facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
fn spawn_legacy_timeout_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy timeout", || {
ServerBuilder::new("facade-http-legacy-timeout", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpSlowTool)
.tool(PublicHttpFastTool)
.build()
})
}
fn spawn_legacy_panic_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy panic", || {
ServerBuilder::new("facade-http-legacy-panic", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpPanicTool)
.tool(PublicHttpFastTool)
.resource(PublicHttpPanicResource)
.resource(PublicHttpPlainResource)
.prompt(PublicHttpPanicPrompt)
.prompt(PublicHttpPlainPrompt)
.prompt(PublicHttpCompletePeerPrompt)
.legacy_completion_handler(PublicHttpPanicCompletion)
.build()
})
}
fn spawn_legacy_surface_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy surface", || {
ServerBuilder::new("facade-http-legacy-surface", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpDefault)
.tool(PublicHttpOutputTool)
.tool(PublicHttpRichTool)
.tool(PublicHttpPlainTool)
.build()
})
}
fn legacy_http_call(
cx: &Cx,
client: &mut legacy_2024::HttpClient,
name: &str,
arguments: serde_json::Value,
label: &'static str,
) -> Result<legacy_2024::CallToolResult, legacy_2024::HttpClientError> {
runtime_block_on_bounded_named(
cx,
label,
client.call_tool(
cx,
legacy_2024::CallToolParams {
name: name.to_owned(),
arguments: Some(arguments),
meta: None,
},
),
)
}
fn legacy_http_content_value(result: &legacy_2024::CallToolResult) -> serde_json::Value {
serde_json::to_value(result).expect("exact-2024 HTTP tool result serializes")
}
#[test]
fn e2e_public_http_legacy_handler_timeout_refuses_late_tool_and_admits_fast_peer() {
let cx = Cx::for_request();
let server = spawn_legacy_timeout_http_server();
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-timeout");
let timed_out = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_SLOW_TOOL_NAME,
json!({}),
"exact-2024 HTTP slow tools/call",
);
let timed_out = match timed_out {
Ok(result) => {
assert!(
result.is_error,
"a handler that outlives its timeout must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
timed_out.contains("Request timeout exceeded") || timed_out.contains("RequestCancelled"),
"the refused late exact-2024 HTTP tools/call must keep the handler-timeout error: {timed_out}"
);
let fast = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_FAST_TOOL_NAME,
json!({}),
"exact-2024 HTTP fast tools/call",
)
.expect("changing only the tool must still be admitted after a handler timeout");
assert_eq!(
legacy_http_tool_text(&fast).as_deref(),
Some("fast"),
"the fast exact-2024 HTTP peer tool must still complete: {fast:?}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_handler_panic_is_sanitized_and_admits_fast_peer() {
let cx = Cx::for_request();
let server = spawn_legacy_panic_http_server();
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-panic");
let panicked = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_PANIC_TOOL_NAME,
json!({}),
"exact-2024 HTTP panic tools/call",
);
let panicked = match panicked {
Ok(result) => {
assert!(
result.is_error,
"a panicking exact-2024 HTTP tools/call must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
panicked.contains("Internal server error") || panicked.contains("InternalError"),
"an exact-2024 HTTP handler panic must become the sanitized InternalError: {panicked}"
);
assert!(
!panicked.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"an exact-2024 HTTP handler panic must not leak the unwind payload: {panicked}"
);
let fast = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_FAST_TOOL_NAME,
json!({}),
"exact-2024 HTTP fast tools/call after panic",
)
.expect("changing only the tool must still be admitted after a sanitized exact-2024 panic");
assert_eq!(
legacy_http_tool_text(&fast).as_deref(),
Some("fast"),
"the fast exact-2024 HTTP peer tool must still complete after a sanitized panic: {fast:?}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_catalog_panic_is_sanitized_and_admits_fast_peer() {
let cx = Cx::for_request();
let server = spawn_legacy_panic_http_server();
let mut client = connect_legacy_http_client(
&cx,
server.address(),
"e2e-public-http-legacy-catalog-panic",
);
let panicked_resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP panic resources/read",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_PANIC_RESOURCE_URI.to_owned(),
meta: None,
},
),
);
let panicked_resource = match panicked_resource {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
panicked_resource.contains("Internal server error")
|| panicked_resource.contains("InternalError"),
"an exact-2024 HTTP resource panic must become the sanitized InternalError: {panicked_resource}"
);
assert!(
!panicked_resource.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"an exact-2024 HTTP resource panic must not leak the unwind payload: {panicked_resource}"
);
let panicked_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP panic prompts/get",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: PUBLIC_HTTP_PANIC_PROMPT_NAME.to_owned(),
arguments: None,
meta: None,
},
),
);
let panicked_prompt = match panicked_prompt {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
panicked_prompt.contains("Internal server error")
|| panicked_prompt.contains("InternalError"),
"an exact-2024 HTTP prompt panic must become the sanitized InternalError: {panicked_prompt}"
);
assert!(
!panicked_prompt.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"an exact-2024 HTTP prompt panic must not leak the unwind payload: {panicked_prompt}"
);
let peer_resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP peer resources/read after panic",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_PLAIN_RESOURCE_URI.to_owned(),
meta: None,
},
),
)
.expect("changing only the resource must still be admitted after a sanitized exact-2024 panic");
let peer_resource = format!("{peer_resource:?}");
assert!(
peer_resource.contains("plain-resource"),
"the exact-2024 HTTP peer resource must still complete after a sanitized catalog panic: {peer_resource}"
);
let peer_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP peer prompts/get after panic",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: PUBLIC_HTTP_PLAIN_PROMPT_NAME.to_owned(),
arguments: None,
meta: None,
},
),
)
.expect("changing only the prompt must still be admitted after a sanitized exact-2024 panic");
let peer_prompt = format!("{peer_prompt:?}");
assert!(
peer_prompt.contains("plain") || !peer_prompt.is_empty(),
"the exact-2024 HTTP peer prompt must still complete after a sanitized catalog panic: {peer_prompt}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_completion_panic_is_sanitized_and_admits_fast_peer() {
let cx = Cx::for_request();
let server = spawn_legacy_panic_http_server();
let mut client = connect_legacy_http_client(
&cx,
server.address(),
"e2e-public-http-legacy-completion-panic",
);
let panicked = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP panic completion/complete",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt {
name: PUBLIC_HTTP_COMPLETE_PEER_PROMPT_NAME.to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
meta: None,
},
),
);
let panicked = match panicked {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
panicked.contains("Internal server error") || panicked.contains("InternalError"),
"an exact-2024 HTTP completion panic must become the sanitized InternalError: {panicked}"
);
assert!(
!panicked.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"an exact-2024 HTTP completion panic must not leak the unwind payload: {panicked}"
);
let peer_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP peer prompts/get after completion panic",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: PUBLIC_HTTP_COMPLETE_PEER_PROMPT_NAME.to_owned(),
arguments: None,
meta: None,
},
),
)
.expect("changing only the method must still admit prompts/get after a sanitized exact-2024 completion panic");
let peer_prompt = format!("{peer_prompt:?}");
assert!(
peer_prompt.contains("complete-peer"),
"the exact-2024 HTTP peer prompt must still complete after a sanitized completion panic: {peer_prompt}"
);
let fast = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_FAST_TOOL_NAME,
json!({}),
"exact-2024 HTTP fast tools/call after completion panic",
)
.expect("changing only the method must still admit tools/call after a sanitized exact-2024 completion panic");
assert_eq!(
legacy_http_tool_text(&fast).as_deref(),
Some("fast"),
"the fast exact-2024 HTTP peer tool must still complete after a sanitized completion panic: {fast:?}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_resource_template_completion_is_retained_and_unregistered_template_is_refused()
{
let cx = Cx::for_request();
let server = spawn_legacy_template_completion_http_server();
let mut client = connect_legacy_http_client(
&cx,
server.address(),
"e2e-public-http-legacy-template-complete",
);
assert!(
client.server_capabilities().completions.is_some(),
"installing a legacy resource-template completion provider must advertise completions: {:?}",
client.server_capabilities()
);
let completed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP template completion/complete",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Resource {
uri: PUBLIC_HTTP_CURSOR_TEMPLATE.to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "id".to_owned(),
value: "al".to_owned(),
},
meta: None,
},
),
)
.expect("live exact-2024 HTTP must complete the registered resource template provider");
assert_eq!(
completed.completion.values,
vec![PUBLIC_HTTP_TEMPLATE_COMPLETION_LEGACY_VALUE.to_owned()],
"the exact-2024 template provider must retain its values: {completed:?}"
);
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP unregistered template completion",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Resource {
uri: PUBLIC_HTTP_OTHER_TEMPLATE.to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "id".to_owned(),
value: "al".to_owned(),
},
meta: None,
},
),
)
.expect_err("changing only the template URI must refuse a missing exact-2024 provider");
let missing = format!("{missing:?}");
assert!(
missing.contains("MethodNotFound") || missing.contains("InvalidParams"),
"an unregistered exact-2024 template completion must stay refused: {missing}"
);
let fast = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_FAST_TOOL_NAME,
json!({}),
"exact-2024 HTTP fast tools/call after template completion",
)
.expect("changing only the method must still admit tools/call after template completion");
assert_eq!(
legacy_http_tool_text(&fast).as_deref(),
Some("fast"),
"the fast exact-2024 HTTP peer tool must still complete after template completion: {fast:?}"
);
drop(client);
server.shutdown();
}
fn spawn_modern_compose_prompt_http_server() -> HttpServerFixture {
spawn_legacy_http_server("modern compose prompt", || {
ServerBuilder::new("facade-http-compose-prompt", "1.0.0")
.protocol_policy(ProtocolPolicy::ModernOnly)
.expect("ModernOnly is available")
.tool(PublicHttpPromptCompose)
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.prompt(PublicHttpFromPromptPrompt)
.resource(PublicHttpResourceComposesPromptResource)
.resource(PublicHttpResourceComposesMissingPromptResource)
.build()
})
}
fn spawn_legacy_compose_prompt_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy compose prompt", || {
ServerBuilder::new("facade-http-legacy-compose-prompt", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpPromptCompose)
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.prompt(PublicHttpFromPromptPrompt)
.resource(PublicHttpResourceComposesPromptResource)
.resource(PublicHttpResourceComposesMissingPromptResource)
.build()
})
}
#[test]
fn e2e_public_http_composes_nested_prompt_and_refuses_missing_name() {
let cx = Cx::for_request();
let server = spawn_modern_compose_prompt_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-compose-prompt", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the compose-prompt HTTP server");
let composed = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_PROMPT_COMPOSE_TOOL_NAME,
json!({"subject": "alpha"}),
),
)
.expect("live bind_http compose_prompt must nest the instruction prompt");
assert!(
composed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "compose-prompt:prompt:alpha",
_ => false,
}),
"compose_prompt must retain the nested prompt text: {composed:?}"
);
let missing = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_PROMPT_COMPOSE_TOOL_NAME,
json!({
"subject": "alpha",
"prompt": "public-http-e2e-missing",
}),
),
);
let missing = match missing {
Ok(result) if result.is_error => format!("{result:?}"),
Err(error) => format!("{error:?}"),
Ok(result) => panic!("changing only the nested prompt name must refuse: {result:?}"),
};
assert!(
missing.contains("public-http-e2e-missing")
|| missing.contains("compose-nested-prompt")
|| missing.contains("not found"),
"the nested unknown prompt must stay a handler-visible refusal: {missing}"
);
let fast = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.expect("changing only the method must still admit tools/call after prompt compose");
assert!(
fast.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"the fast peer tool must still complete after prompt compose: {fast:?}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_composes_nested_prompt_and_refuses_missing_name() {
let cx = Cx::for_request();
let server = spawn_legacy_compose_prompt_http_server();
let mut client = connect_legacy_http_client(
&cx,
server.address(),
"e2e-public-http-legacy-compose-prompt",
);
let composed = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_PROMPT_COMPOSE_TOOL_NAME,
json!({"subject": "alpha"}),
"exact-2024 HTTP compose_prompt",
)
.expect("live exact-2024 HTTP compose_prompt must nest the instruction prompt");
assert_eq!(
legacy_http_tool_text(&composed).as_deref(),
Some("compose-prompt:prompt:alpha"),
"compose_prompt must retain the nested prompt text: {composed:?}"
);
let missing = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_PROMPT_COMPOSE_TOOL_NAME,
json!({
"subject": "alpha",
"prompt": "public-http-e2e-missing",
}),
"exact-2024 HTTP missing nested prompt",
);
let missing = match missing {
Ok(result) if result.is_error => format!("{result:?}"),
Err(error) => format!("{error:?}"),
Ok(result) => panic!("changing only the nested prompt name must refuse: {result:?}"),
};
assert!(
missing.contains("public-http-e2e-missing")
|| missing.contains("compose-nested-prompt")
|| missing.contains("not found"),
"the nested unknown prompt must stay a handler-visible refusal: {missing}"
);
let fast = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_FAST_TOOL_NAME,
json!({}),
"exact-2024 HTTP fast tools/call after prompt compose",
)
.expect("changing only the method must still admit tools/call after prompt compose");
assert_eq!(
legacy_http_tool_text(&fast).as_deref(),
Some("fast"),
"the fast exact-2024 HTTP peer tool must still complete after prompt compose: {fast:?}"
);
drop(client);
server.shutdown();
}
fn nested_prompt_refusal(error: &str) -> bool {
error.contains("public-http-e2e-missing")
|| error.contains("compose-nested-prompt")
|| error.contains("not found")
}
#[test]
fn e2e_public_http_resource_and_prompt_compose_nested_prompt_and_refuse_missing_name() {
let cx = Cx::for_request();
let server = spawn_modern_compose_prompt_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-catalog-compose-prompt", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the catalog compose-prompt HTTP server");
let resource = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_PROMPT_COMPOSE_RESOURCE_URI),
)
.expect("live bind_http resources/read must nest the instruction prompt");
assert!(
matches!(
resource.contents.as_slice(),
[EmbeddedResourceContents::Text { text, .. }]
if text == "compose-prompt:prompt:alpha"
),
"resource ctx.get_prompt must retain the nested prompt text: {resource:?}"
);
let missing_resource = runtime_block_on_bounded(
&cx,
client.read_resource(&cx, PUBLIC_HTTP_PROMPT_COMPOSE_MISSING_RESOURCE_URI),
);
let missing_resource = match missing_resource {
Ok(result) => {
panic!("changing only the nested prompt name must refuse resources/read: {result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
nested_prompt_refusal(&missing_resource),
"the nested unknown prompt must stay a handler-visible resource refusal: {missing_resource}"
);
let prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
PUBLIC_HTTP_FROM_PROMPT_NAME,
HashMap::from([("subject".to_owned(), "alpha".to_owned())]),
),
)
.expect("live bind_http prompts/get must nest the instruction prompt");
assert!(
prompt
.messages
.iter()
.any(|message| match &message.content {
ContentBlock::Text { text, .. } => text == "compose-prompt:prompt:alpha",
_ => false,
}),
"prompt ctx.get_prompt must retain the nested prompt text: {prompt:?}"
);
let missing_prompt = runtime_block_on_bounded(
&cx,
client.get_prompt(
&cx,
PUBLIC_HTTP_FROM_PROMPT_NAME,
HashMap::from([
("subject".to_owned(), "alpha".to_owned()),
("prompt".to_owned(), "public-http-e2e-missing".to_owned()),
]),
),
);
let missing_prompt = match missing_prompt {
Ok(result) => {
panic!("changing only the nested prompt name must refuse prompts/get: {result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
nested_prompt_refusal(&missing_prompt),
"the nested unknown prompt must stay a handler-visible prompt refusal: {missing_prompt}"
);
let fast = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.expect("changing only the method must still admit tools/call after catalog prompt compose");
assert!(
fast.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"the fast peer tool must still complete after catalog prompt compose: {fast:?}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_resource_and_prompt_compose_nested_prompt_and_refuse_missing_name() {
let cx = Cx::for_request();
let server = spawn_legacy_compose_prompt_http_server();
let mut client = connect_legacy_http_client(
&cx,
server.address(),
"e2e-public-http-legacy-catalog-compose-prompt",
);
let resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP resource compose-prompt",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_PROMPT_COMPOSE_RESOURCE_URI.to_owned(),
meta: None,
},
),
)
.expect("live exact-2024 HTTP resources/read must nest the instruction prompt");
let resource =
serde_json::to_value(resource).expect("the exact-2024 HTTP resource result serializes");
assert_eq!(
resource["contents"][0]["text"],
json!("compose-prompt:prompt:alpha"),
"exact-2024 HTTP resource ctx.get_prompt must retain the nested prompt text: {resource}"
);
let missing_resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP missing nested prompt from resources/read",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_PROMPT_COMPOSE_MISSING_RESOURCE_URI.to_owned(),
meta: None,
},
),
);
let missing_resource = match missing_resource {
Ok(result) => {
panic!("changing only the nested prompt name must refuse resources/read: {result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
nested_prompt_refusal(&missing_resource),
"the nested unknown prompt must stay a handler-visible resource refusal: {missing_resource}"
);
let prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP prompt compose-prompt",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: PUBLIC_HTTP_FROM_PROMPT_NAME.to_owned(),
arguments: Some(HashMap::from([("subject".to_owned(), "alpha".to_owned())])),
meta: None,
},
),
)
.expect("live exact-2024 HTTP prompts/get must nest the instruction prompt");
let prompt =
serde_json::to_value(prompt).expect("the exact-2024 HTTP prompt result serializes");
assert_eq!(
prompt["messages"][0]["content"]["text"],
json!("compose-prompt:prompt:alpha"),
"exact-2024 HTTP prompt ctx.get_prompt must retain the nested prompt text: {prompt}"
);
let missing_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP missing nested prompt from prompts/get",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: PUBLIC_HTTP_FROM_PROMPT_NAME.to_owned(),
arguments: Some(HashMap::from([
("subject".to_owned(), "alpha".to_owned()),
("prompt".to_owned(), "public-http-e2e-missing".to_owned()),
])),
meta: None,
},
),
);
let missing_prompt = match missing_prompt {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
nested_prompt_refusal(&missing_prompt),
"the nested unknown prompt must stay a handler-visible prompt refusal: {missing_prompt}"
);
let fast = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_FAST_TOOL_NAME,
json!({}),
"exact-2024 HTTP fast tools/call after catalog prompt compose",
)
.expect("changing only the method must still admit tools/call after catalog prompt compose");
assert_eq!(
legacy_http_tool_text(&fast).as_deref(),
Some("fast"),
"the fast exact-2024 HTTP peer tool must still complete after catalog prompt compose: {fast:?}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_default_parameter_is_injected_and_overridable() {
let cx = Cx::for_request();
let server = spawn_legacy_surface_http_server();
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-default");
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP tools/list defaults",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("live exact-2024 HTTP must list the default-parameter tool");
let default_tool = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_DEFAULT_TOOL_NAME)
.expect("the default-parameter tool must remain on the live catalog");
let required = default_tool
.input_schema
.get("required")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
assert!(
required.iter().any(|value| value.as_str() == Some("name")),
"the generated schema must still require name: {default_tool:?}"
);
assert!(
!required
.iter()
.any(|value| value.as_str() == Some("suffix")),
"the defaulted suffix must not stay required: {default_tool:?}"
);
assert_eq!(
default_tool
.input_schema
.pointer("/properties/suffix/default")
.and_then(serde_json::Value::as_str),
Some(PUBLIC_HTTP_DEFAULT_SUFFIX),
"exact-2024 HTTP tools/list must retain the generated default: {default_tool:?}"
);
let injected = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_DEFAULT_TOOL_NAME,
json!({"name": "World"}),
"exact-2024 HTTP default inject",
)
.expect("omitting the defaulted argument must still be admitted");
assert_eq!(
legacy_http_tool_text(&injected).as_deref(),
Some("greet:World!"),
"the generated default must be injected at exact-2024 HTTP call time: {injected:?}"
);
let overridden = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_DEFAULT_TOOL_NAME,
json!({"name": "World", "suffix": "?"}),
"exact-2024 HTTP default override",
)
.expect("supplying the defaulted argument must override the generated default");
assert_eq!(
legacy_http_tool_text(&overridden).as_deref(),
Some("greet:World?"),
"changing only the suffix must override the generated default: {overridden:?}"
);
let missing_name = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_DEFAULT_TOOL_NAME,
json!({"suffix": "!"}),
"exact-2024 HTTP missing required default sibling",
);
let missing_name = match missing_name {
Ok(result) => {
assert!(
result.is_error,
"a missing required generated argument must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
missing_name.contains("name")
|| missing_name.contains("required")
|| missing_name.contains("schema")
|| missing_name.contains("InvalidParams")
|| missing_name.contains("do not match"),
"a missing required generated argument must stay a handler-visible refusal: {missing_name}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_output_schema_is_listed_and_call_stays_unstructured() {
let cx = Cx::for_request();
let server = spawn_legacy_surface_http_server();
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-output");
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP tools/list output schema",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("live exact-2024 HTTP must list the output-schema tools");
let output_tool = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_OUTPUT_TOOL_NAME)
.expect("the output-schema tool must remain on the live catalog");
let plain_tool = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_PLAIN_TOOL_NAME)
.expect("the bare peer tool must remain on the live catalog");
assert_eq!(
output_tool
.output_schema
.as_ref()
.and_then(|schema| schema.get("required")),
Some(&json!(["value"])),
"exact-2024 HTTP tools/list must retain the advertised output schema: {output_tool:?}"
);
assert_eq!(
plain_tool.output_schema, None,
"changing only the missing output schema must keep the peer bare: {plain_tool:?}"
);
let called = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_OUTPUT_TOOL_NAME,
json!({"value": "alpha"}),
"exact-2024 HTTP structured tools/call",
)
.expect("live exact-2024 HTTP must admit the structured output tool");
assert_eq!(
legacy_http_tool_text(&called).as_deref(),
Some("tool:alpha"),
"the structured tool must still author text content: {called:?}"
);
let called_value = legacy_http_content_value(&called);
assert!(
called_value.get("structuredContent").is_none()
|| called_value.get("structuredContent") == Some(&serde_json::Value::Null),
"exact-2024 HTTP must not invent structuredContent: {called_value}"
);
let peer = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_PLAIN_TOOL_NAME,
json!({}),
"exact-2024 HTTP bare peer tools/call",
)
.expect("the bare peer tool must still be callable");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("plain"),
"the text-only peer tool must still complete: {peer:?}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_rich_content_retains_image_and_peer_stays_text() {
let cx = Cx::for_request();
let server = spawn_legacy_surface_http_server();
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-rich");
let rich = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_RICH_TOOL_NAME,
json!({}),
"exact-2024 HTTP rich tools/call",
)
.expect("live exact-2024 HTTP must retain the representable image block");
let rich_value = legacy_http_content_value(&rich);
let content = rich_value
.get("content")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
assert!(
content.iter().any(|block| {
block.get("type").and_then(serde_json::Value::as_str) == Some("image")
&& block.get("data").and_then(serde_json::Value::as_str)
== Some(PUBLIC_HTTP_IMAGE_DATA)
&& block.get("mimeType").and_then(serde_json::Value::as_str)
== Some(PUBLIC_HTTP_IMAGE_MIME)
}),
"exact-2024 HTTP tools/call must retain the authored image block: {rich_value}"
);
assert!(
content.iter().all(|block| {
block.get("type").and_then(serde_json::Value::as_str) != Some("audio")
}),
"exact-2024 HTTP must not invent an audio block: {rich_value}"
);
let peer = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_PLAIN_TOOL_NAME,
json!({}),
"exact-2024 HTTP text-only peer tools/call",
)
.expect("the text-only peer tool must still be callable");
let peer_value = legacy_http_content_value(&peer);
let peer_content = peer_value
.get("content")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
assert!(
peer_content
.iter()
.all(|block| { block.get("type").and_then(serde_json::Value::as_str) == Some("text") }),
"changing only the missing rich content must keep the peer text-only: {peer_value}"
);
drop(client);
server.shutdown();
}
fn spawn_legacy_lifespan_http_server(
startup: Option<Arc<AtomicBool>>,
shutdown: Option<Arc<AtomicBool>>,
) -> HttpServerFixture {
spawn_legacy_http_server("legacy lifespan", move || {
let builder = ServerBuilder::new("facade-http-legacy-lifespan", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpValue);
let builder = match startup {
Some(flag) => builder.on_startup(move || -> Result<(), std::io::Error> {
flag.store(true, Ordering::SeqCst);
Ok(())
}),
None => builder,
};
let builder = match shutdown {
Some(flag) => builder.on_shutdown(move || {
flag.store(true, Ordering::SeqCst);
}),
None => builder,
};
builder.build()
})
}
#[test]
fn e2e_public_http_legacy_lifespan_hooks_run_once_and_peer_stays_unhooked() {
let cx = Cx::for_request();
let hooked_startup = Arc::new(AtomicBool::new(false));
let hooked_shutdown = Arc::new(AtomicBool::new(false));
let bare_startup = Arc::new(AtomicBool::new(false));
let bare_shutdown = Arc::new(AtomicBool::new(false));
let hooked = spawn_legacy_lifespan_http_server(
Some(Arc::clone(&hooked_startup)),
Some(Arc::clone(&hooked_shutdown)),
);
let bare = spawn_legacy_lifespan_http_server(None, None);
let mut hooked_client =
connect_legacy_http_client(&cx, hooked.address(), "e2e-public-http-legacy-lifespan");
let admitted = legacy_http_call(
&cx,
&mut hooked_client,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha"}),
"exact-2024 HTTP tools/call after startup hook",
)
.expect("tools/call must be admitted after the startup hook");
assert_eq!(
legacy_http_tool_text(&admitted).as_deref(),
Some("tool:alpha"),
"the hooked exact-2024 HTTP peer must still return its text: {admitted:?}"
);
assert!(
hooked_startup.load(Ordering::SeqCst),
"LegacyOnly bind_http serve must run the public startup hook before admitting traffic"
);
assert!(
!hooked_shutdown.load(Ordering::SeqCst),
"the shutdown hook must not run while the listener is still serving"
);
assert!(
!bare_startup.load(Ordering::SeqCst) && !bare_shutdown.load(Ordering::SeqCst),
"a peer without hooks must not invent lifespan events"
);
drop(hooked_client);
hooked.shutdown();
assert!(
hooked_shutdown.load(Ordering::SeqCst),
"cooperative exact-2024 HTTP shutdown must run the public shutdown hook"
);
let mut bare_client =
connect_legacy_http_client(&cx, bare.address(), "e2e-public-http-legacy-lifespan-bare");
legacy_http_call(
&cx,
&mut bare_client,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha"}),
"exact-2024 HTTP tools/call on unhooked peer",
)
.expect("the unhooked peer must still admit tools/call");
drop(bare_client);
bare.shutdown();
assert!(
!bare_startup.load(Ordering::SeqCst) && !bare_shutdown.load(Ordering::SeqCst),
"changing only the missing hooks must leave the peer unhooked after shutdown"
);
}
fn spawn_legacy_state_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy state", || {
ServerBuilder::new("facade-http-legacy-state", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpStateTool)
.build()
})
}
#[test]
fn e2e_public_http_legacy_session_state_is_retained_across_sse_calls() {
let cx = Cx::for_request();
let server = spawn_legacy_state_http_server();
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-state");
let mut peer =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-state-peer");
let written = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_STATE_TOOL_NAME,
json!({"action": "write", "value": "alpha"}),
"exact-2024 HTTP session state write",
)
.expect("live exact-2024 HTTP must write session state");
assert_eq!(
legacy_http_tool_text(&written).as_deref(),
Some("state:alpha"),
"set_state then get_state on the same SSE session must retain the value: {written:?}"
);
let later_read = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_STATE_TOOL_NAME,
json!({"action": "read"}),
"exact-2024 HTTP session state later read",
)
.expect("a later exact-2024 HTTP tools/call must still be admitted");
assert_eq!(
legacy_http_tool_text(&later_read).as_deref(),
Some("state:alpha"),
"the same SSE session must reuse the previous bag: {later_read:?}"
);
let peer_read = legacy_http_call(
&cx,
&mut peer,
PUBLIC_HTTP_STATE_TOOL_NAME,
json!({"action": "read"}),
"exact-2024 HTTP session state peer read",
)
.expect("a peer SSE session must still be admitted");
assert_eq!(
legacy_http_tool_text(&peer_read).as_deref(),
Some("state:missing"),
"changing only the SSE session must not reuse the other session bag: {peer_read:?}"
);
drop(client);
drop(peer);
server.shutdown();
}
fn spawn_legacy_mask_http_server(mask_error_details: bool) -> HttpServerFixture {
spawn_legacy_http_server("legacy mask", move || {
ServerBuilder::new("facade-http-legacy-mask", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.mask_error_details(mask_error_details)
.resource(PublicHttpLeakResource)
.build()
})
}
fn spawn_legacy_rate_limit_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy rate-limit", || {
ServerBuilder::new("facade-http-legacy-rate-limit", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.middleware(rate_limiting::RateLimitingMiddleware::new(1.0e-300).burst_capacity(1))
.tool(PublicHttpTouchTool)
.build()
})
}
fn spawn_legacy_sliding_window_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy sliding-window", || {
ServerBuilder::new("facade-http-legacy-sliding-window", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.middleware(rate_limiting::SlidingWindowRateLimitingMiddleware::new(
1, 60,
))
.tool(PublicHttpTouchTool)
.build()
})
}
fn spawn_legacy_strict_validation_http_server(strict: bool) -> HttpServerFixture {
spawn_legacy_http_server("legacy strict", move || {
ServerBuilder::new("facade-http-legacy-strict", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.strict_input_validation(strict)
.tool(PublicHttpValue)
.build()
})
}
#[test]
fn e2e_public_http_legacy_mask_error_details_hides_resource_execution_secret() {
let cx = Cx::for_request();
let masked_server = spawn_legacy_mask_http_server(true);
let mut masked_client =
connect_legacy_http_client(&cx, masked_server.address(), "e2e-public-http-legacy-mask");
let masked = runtime_block_on_bounded_named(
&cx,
"exact-2024 masked resources/read",
masked_client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_LEAK_RESOURCE_URI.to_owned(),
meta: None,
},
),
)
.expect_err("a leaking resource must stay a resources/read error");
let masked = format!("{masked:?}");
assert!(
masked.contains("Internal server error"),
"exact-2024 HTTP mask_error_details must replace the execution secret: {masked}"
);
assert!(
!masked.contains(PUBLIC_HTTP_LEAK_SECRET),
"exact-2024 HTTP mask_error_details must not leak the execution secret: {masked}"
);
drop(masked_client);
masked_server.shutdown();
let unmasked_server = spawn_legacy_mask_http_server(false);
let mut unmasked_client = connect_legacy_http_client(
&cx,
unmasked_server.address(),
"e2e-public-http-legacy-unmask",
);
let unmasked = runtime_block_on_bounded_named(
&cx,
"exact-2024 unmasked resources/read",
unmasked_client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_LEAK_RESOURCE_URI.to_owned(),
meta: None,
},
),
)
.expect_err("changing only mask_error_details must still refuse the leaking resource");
let unmasked = format!("{unmasked:?}");
assert!(
unmasked.contains(PUBLIC_HTTP_LEAK_SECRET),
"disabling exact-2024 HTTP mask_error_details must keep the execution secret: {unmasked}"
);
drop(unmasked_client);
unmasked_server.shutdown();
}
#[test]
fn e2e_public_http_legacy_rate_limit_refuses_second_same_method_and_admits_another() {
let cx = Cx::for_request();
let server = spawn_legacy_rate_limit_http_server();
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-rate-limit");
let first = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_TOUCH_TOOL_NAME,
json!({}),
"exact-2024 first rate-limited tools/call",
)
.expect("the first exact-2024 HTTP tools/call must be admitted by the live rate limiter");
let first_text = legacy_http_tool_text(&first);
assert!(
first_text.as_deref() == Some("notified") || first_text.as_deref() == Some("silent"),
"the first live tools/call must reach the touch handler: {first:?}"
);
let limited = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_TOUCH_TOOL_NAME,
json!({}),
"exact-2024 second rate-limited tools/call",
);
let limited = match limited {
Ok(result) => {
assert!(
result.is_error,
"a second tools/call must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
limited.contains("Rate limit exceeded"),
"the refused second exact-2024 HTTP tools/call must keep the rate-limit error: {limited}"
);
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 tools/list after rate limit",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("changing only the method must still be admitted by the live rate limiter");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOUCH_TOOL_NAME),
"exact-2024 HTTP tools/list must stay callable after tools/call is rate-limited: {listed:?}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_sliding_window_refuses_second_same_method_and_admits_another() {
let cx = Cx::for_request();
let server = spawn_legacy_sliding_window_http_server();
let mut client = connect_legacy_http_client(
&cx,
server.address(),
"e2e-public-http-legacy-sliding-window",
);
let first = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_TOUCH_TOOL_NAME,
json!({}),
"exact-2024 first sliding-window tools/call",
)
.expect("the first exact-2024 HTTP tools/call must be admitted by the sliding window");
let first_text = legacy_http_tool_text(&first);
assert!(
first_text.as_deref() == Some("notified") || first_text.as_deref() == Some("silent"),
"the first live tools/call must reach the touch handler: {first:?}"
);
let limited = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_TOUCH_TOOL_NAME,
json!({}),
"exact-2024 second sliding-window tools/call",
);
let limited = match limited {
Ok(result) => {
assert!(
result.is_error,
"a second tools/call must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
limited.contains("Rate limit exceeded"),
"the refused second exact-2024 HTTP tools/call must keep the sliding-window error: {limited}"
);
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 tools/list after sliding window",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("changing only the method must still be admitted by the sliding window");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOUCH_TOOL_NAME),
"exact-2024 HTTP tools/list must stay callable after tools/call is window-limited: {listed:?}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_strict_input_validation_refuses_unknown_property_and_admits_declared_args()
{
let cx = Cx::for_request();
let strict_server = spawn_legacy_strict_validation_http_server(true);
let mut strict_client = connect_legacy_http_client(
&cx,
strict_server.address(),
"e2e-public-http-legacy-strict-on",
);
let admitted = legacy_http_call(
&cx,
&mut strict_client,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha"}),
"exact-2024 strict declared tools/call",
)
.expect("strict validation must still admit declared arguments");
assert!(
!admitted.is_error,
"declared arguments must not become a tool-level error: {admitted:?}"
);
assert_eq!(
legacy_http_tool_text(&admitted).as_deref(),
Some("tool:alpha"),
"declared arguments must reach the handler under strict validation: {admitted:?}"
);
let refused = legacy_http_call(
&cx,
&mut strict_client,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha", "extra": 1}),
"exact-2024 strict extra-property tools/call",
);
let refused = match refused {
Ok(result) => {
assert!(
result.is_error,
"an unknown property must become a tool-level error when strict validation is on: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
refused.contains("do not match the declared input schema")
|| refused.contains("unknown")
|| refused.contains("InvalidParams")
|| refused.contains("extra"),
"the strict refusal must name the input-schema mismatch: {refused}"
);
drop(strict_client);
strict_server.shutdown();
let lenient_server = spawn_legacy_strict_validation_http_server(false);
let mut lenient_client = connect_legacy_http_client(
&cx,
lenient_server.address(),
"e2e-public-http-legacy-strict-off",
);
let extra = legacy_http_call(
&cx,
&mut lenient_client,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha", "extra": 1}),
"exact-2024 lenient extra-property tools/call",
)
.expect("lenient validation must admit the same extra property");
assert!(
!extra.is_error,
"changing only the strict flag must admit the extra property: {extra:?}"
);
assert_eq!(
legacy_http_tool_text(&extra).as_deref(),
Some("tool:alpha"),
"the extra property must still reach the handler when strict validation is off: {extra:?}"
);
drop(lenient_client);
lenient_server.shutdown();
}
fn spawn_legacy_auth_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let tool_calls = Arc::clone(&handler_calls);
let mut server = spawn_legacy_http_server("legacy auth", move || {
let verifier = StaticTokenVerifier::new([(
"alpha",
AuthContext::with_subject(PUBLIC_HTTP_AUTH_SUBJECT),
)])
.expect("the deterministic native bearer verifier is valid")
.with_allowed_schemes(["Bearer"])
.expect("the bearer scheme allowlist is valid");
ServerBuilder::new("facade-http-legacy-auth", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.auth_provider(TokenAuthProvider::new(verifier))
.tool(PublicHttpAuthSubject {
counters: tool_calls,
})
.build()
});
server.handler_calls = handler_calls;
server
}
const MAX_LEGACY_NATIVE_HTTP_RESPONSE_BYTES: usize = 1 << 20;
fn write_legacy_native_http(
address: SocketAddr,
method: &str,
path: &str,
authorization: Option<&str>,
body: &[u8],
keep_open: bool,
) -> (std::net::TcpStream, Vec<u8>) {
let mut stream = std::net::TcpStream::connect_timeout(&address, HTTP_OPERATION_BOUND)
.expect("native HTTP client connects to the exact-2024 listener");
stream
.set_read_timeout(Some(HTTP_OPERATION_BOUND))
.expect("native HTTP client read deadline is configured");
stream
.set_write_timeout(Some(HTTP_OPERATION_BOUND))
.expect("native HTTP client write deadline is configured");
let authorization_header =
authorization.map_or_else(String::new, |value| format!("Authorization: {value}\r\n"));
let extra_headers = if body.is_empty() {
String::new()
} else {
format!(
"Content-Type: application/json\r\nContent-Length: {}\r\n",
body.len()
)
};
let request = format!(
"{method} {path} HTTP/1.1\r\nHost: {address}\r\n{authorization_header}Accept: text/event-stream\r\n{extra_headers}\r\n"
);
stream
.write_all(request.as_bytes())
.and_then(|()| stream.write_all(body))
.expect("native HTTP request commits to the exact-2024 listener");
if !keep_open {
let _ = stream.shutdown(std::net::Shutdown::Write);
}
let mut response = Vec::new();
let mut buffer = [0_u8; 8 * 1024];
let deadline = Instant::now() + HTTP_OPERATION_BOUND;
loop {
if Instant::now() >= deadline {
break;
}
match stream.read(&mut buffer) {
Ok(0) => break,
Ok(read) => {
assert!(
response
.len()
.checked_add(read)
.is_some_and(|size| size <= MAX_LEGACY_NATIVE_HTTP_RESPONSE_BYTES),
"native HTTP response exceeds the test's bounded response budget"
);
response.extend_from_slice(&buffer[..read]);
if !keep_open {
continue;
}
if response.windows(4).any(|window| window == b"\r\n\r\n")
&& (response.starts_with(b"HTTP/1.1 401")
|| response
.windows(b"event: endpoint".len())
.any(|window| window == b"event: endpoint"))
{
break;
}
}
Err(error)
if error.kind() == std::io::ErrorKind::WouldBlock
|| error.kind() == std::io::ErrorKind::TimedOut =>
{
break;
}
Err(error) => {
panic!("native HTTP response reads within its configured deadline: {error}")
}
}
}
(stream, response)
}
fn legacy_native_http_headers(response: &[u8]) -> &str {
let header_end = response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.unwrap_or(response.len());
std::str::from_utf8(&response[..header_end]).unwrap_or("")
}
fn assert_legacy_bearer_challenge(response: &[u8], label: &str) {
assert!(
response.starts_with(b"HTTP/1.1 401"),
"{label} must be refused before dispatch: {}",
String::from_utf8_lossy(response)
);
assert!(
legacy_native_http_headers(response)
.lines()
.any(
|header| header.to_ascii_lowercase().starts_with("www-authenticate:")
&& header.to_ascii_lowercase().contains("bearer")
),
"{label} challenge must advertise Bearer: {}",
String::from_utf8_lossy(response)
);
}
fn legacy_sse_session_id(response: &[u8]) -> String {
let text = String::from_utf8_lossy(response);
let data = text
.split("data:")
.nth(1)
.expect("exact-2024 SSE open emits an endpoint event");
let endpoint = data
.lines()
.next()
.expect("endpoint data is one line")
.trim();
endpoint
.split("session_id=")
.nth(1)
.and_then(|rest| rest.split(['&', ' ', '\r', '\n']).next())
.filter(|id| !id.is_empty())
.expect("endpoint event carries session_id")
.to_owned()
}
fn drain_legacy_sse(
stream: &mut std::net::TcpStream,
haystack: &mut Vec<u8>,
bound: Duration,
) -> String {
let previous = stream.read_timeout().ok().flatten();
stream
.set_read_timeout(Some(bound))
.expect("legacy SSE drain deadline is configured");
let mut buffer = [0_u8; 8 * 1024];
let deadline = Instant::now() + bound;
while Instant::now() < deadline {
match stream.read(&mut buffer) {
Ok(0) => break,
Ok(read) => {
assert!(
haystack
.len()
.checked_add(read)
.is_some_and(|size| size <= MAX_LEGACY_NATIVE_HTTP_RESPONSE_BYTES),
"legacy SSE drain exceeds the test's bounded response budget"
);
haystack.extend_from_slice(&buffer[..read]);
}
Err(error)
if error.kind() == std::io::ErrorKind::WouldBlock
|| error.kind() == std::io::ErrorKind::TimedOut =>
{
break;
}
Err(error) => panic!("legacy SSE drain reads within its configured deadline: {error}"),
}
}
if let Some(timeout) = previous {
let _ = stream.set_read_timeout(Some(timeout));
}
String::from_utf8_lossy(haystack).into_owned()
}
fn read_legacy_sse_until(
stream: &mut std::net::TcpStream,
haystack: &mut Vec<u8>,
needle: &str,
) -> String {
let mut buffer = [0_u8; 8 * 1024];
let deadline = Instant::now() + HTTP_OPERATION_BOUND;
loop {
if String::from_utf8_lossy(haystack).contains(needle) {
return String::from_utf8_lossy(haystack).into_owned();
}
if Instant::now() >= deadline {
panic!(
"exact-2024 SSE did not retain {needle:?} within the bound: {}",
String::from_utf8_lossy(haystack)
);
}
match stream.read(&mut buffer) {
Ok(0) => panic!(
"exact-2024 SSE closed before retaining {needle:?}: {}",
String::from_utf8_lossy(haystack)
),
Ok(read) => {
assert!(
haystack
.len()
.checked_add(read)
.is_some_and(|size| size <= MAX_LEGACY_NATIVE_HTTP_RESPONSE_BYTES),
"exact-2024 SSE exceeds the test's bounded response budget"
);
haystack.extend_from_slice(&buffer[..read]);
}
Err(error)
if error.kind() == std::io::ErrorKind::WouldBlock
|| error.kind() == std::io::ErrorKind::TimedOut => {}
Err(error) => panic!("exact-2024 SSE read failed: {error}"),
}
}
}
fn initialize_legacy_native_session(
address: SocketAddr,
authorization: Option<&str>,
client_name: &str,
) -> (std::net::TcpStream, Vec<u8>, String) {
let (sse, opened) = write_legacy_native_http(address, "GET", "/sse", authorization, &[], true);
assert!(
opened.starts_with(b"HTTP/1.1 200"),
"GET /sse must open the exact-2024 session: {}",
String::from_utf8_lossy(&opened)
);
let session_id = legacy_sse_session_id(&opened);
let messages = format!("/messages?session_id={session_id}");
let initialize = JsonRpcRequest::new(
"initialize",
Some(json!({
"protocolVersion": legacy_2024::PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": { "name": client_name, "version": "1.0.0" },
})),
1_i64,
);
let initialize_body =
serde_json::to_vec(&initialize).expect("exact-2024 initialize serializes");
let (_init_stream, init_response) = write_legacy_native_http(
address,
"POST",
&messages,
authorization,
&initialize_body,
false,
);
assert!(
init_response.starts_with(b"HTTP/1.1 202") || init_response.starts_with(b"HTTP/1.1 200"),
"initialize must be admitted: {}",
String::from_utf8_lossy(&init_response)
);
let initialized = json!({
"jsonrpc": "2.0",
"method": "notifications/initialized",
});
let initialized_body =
serde_json::to_vec(&initialized).expect("exact-2024 initialized serializes");
let (_initialized_stream, initialized_response) = write_legacy_native_http(
address,
"POST",
&messages,
authorization,
&initialized_body,
false,
);
assert!(
initialized_response.starts_with(b"HTTP/1.1 202")
|| initialized_response.starts_with(b"HTTP/1.1 200"),
"initialized notification must be admitted: {}",
String::from_utf8_lossy(&initialized_response)
);
(sse, opened, messages)
}
#[test]
fn e2e_public_http_legacy_static_token_refuses_missing_and_wrong_and_commits_subject() {
let server = spawn_legacy_auth_http_server();
let (_missing_stream, missing) =
write_legacy_native_http(server.address(), "GET", "/sse", None, &[], false);
assert_legacy_bearer_challenge(&missing, "missing Authorization on GET /sse");
assert_eq!(
server.handler_call_snapshot().tool,
0,
"missing Authorization must not invoke the authenticated handler"
);
let (_wrong_stream, wrong) = write_legacy_native_http(
server.address(),
"GET",
"/sse",
Some("Bearer beta"),
&[],
false,
);
assert_legacy_bearer_challenge(&wrong, "wrong bearer on GET /sse");
assert_eq!(
server.handler_call_snapshot().tool,
0,
"a wrong bearer token must not invoke the authenticated handler"
);
let (mut sse, opened, messages) = initialize_legacy_native_session(
server.address(),
Some("Bearer alpha"),
"e2e-public-http-legacy-auth",
);
let tool = JsonRpcRequest::new(
"tools/call",
Some(json!({
"name": PUBLIC_HTTP_AUTH_TOOL_NAME,
"arguments": {},
})),
2_i64,
);
let tool_body = serde_json::to_vec(&tool).expect("exact-2024 tools/call serializes");
let (_missing_post, missing_post) =
write_legacy_native_http(server.address(), "POST", &messages, None, &tool_body, false);
assert_legacy_bearer_challenge(&missing_post, "missing Authorization on POST /messages");
assert_eq!(
server.handler_call_snapshot().tool,
0,
"missing Authorization on POST /messages must not invoke the handler"
);
let (_wrong_post, wrong_post) = write_legacy_native_http(
server.address(),
"POST",
&messages,
Some("Bearer beta"),
&tool_body,
false,
);
assert_legacy_bearer_challenge(&wrong_post, "wrong bearer on POST /messages");
assert_eq!(
server.handler_call_snapshot().tool,
0,
"a wrong bearer token on POST /messages must not invoke the handler"
);
let (_tool_stream, tool_response) = write_legacy_native_http(
server.address(),
"POST",
&messages,
Some("Bearer alpha"),
&tool_body,
false,
);
assert!(
tool_response.starts_with(b"HTTP/1.1 202") || tool_response.starts_with(b"HTTP/1.1 200"),
"the matching bearer token must admit tools/call: {}",
String::from_utf8_lossy(&tool_response)
);
let mut sse_body = opened;
let retained = read_legacy_sse_until(
&mut sse,
&mut sse_body,
&format!("subject:{PUBLIC_HTTP_AUTH_SUBJECT}"),
);
assert!(
retained.contains(&format!("subject:{PUBLIC_HTTP_AUTH_SUBJECT}")),
"admitted exact-2024 HTTP must commit the verifier subject into ctx.auth(): {retained}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"only the matching bearer token may invoke the authenticated handler"
);
drop(sse);
server.shutdown();
}
fn spawn_legacy_custom_auth_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let tool_calls = Arc::clone(&handler_calls);
let mut server = spawn_legacy_http_server("legacy custom auth", move || {
ServerBuilder::new("facade-http-legacy-custom-auth", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.auth_provider(TokenAuthProvider::new(PublicHttpCustomVerifier))
.tool(PublicHttpAuthSubject {
counters: tool_calls,
})
.build()
});
server.handler_calls = handler_calls;
server
}
#[test]
fn e2e_public_http_legacy_custom_token_refuses_wrong_and_commits_subject() {
let server = spawn_legacy_custom_auth_http_server();
let matching = format!("Bearer {PUBLIC_HTTP_CUSTOM_AUTH_TOKEN}");
let (_missing_stream, missing) =
write_legacy_native_http(server.address(), "GET", "/sse", None, &[], false);
assert_legacy_bearer_challenge(&missing, "missing Authorization on custom GET /sse");
assert_eq!(
server.handler_call_snapshot().tool,
0,
"missing Authorization must not invoke the custom-verified handler"
);
let (_wrong_stream, wrong) = write_legacy_native_http(
server.address(),
"GET",
"/sse",
Some("Bearer alpha"),
&[],
false,
);
assert_legacy_bearer_challenge(&wrong, "static token on custom GET /sse");
assert_eq!(
server.handler_call_snapshot().tool,
0,
"changing only the custom verifier token must not invoke the handler"
);
let (mut sse, opened, messages) = initialize_legacy_native_session(
server.address(),
Some(matching.as_str()),
"e2e-public-http-legacy-custom-auth",
);
let tool = JsonRpcRequest::new(
"tools/call",
Some(json!({
"name": PUBLIC_HTTP_AUTH_TOOL_NAME,
"arguments": {},
})),
2_i64,
);
let tool_body = serde_json::to_vec(&tool).expect("exact-2024 tools/call serializes");
let (_wrong_post, wrong_post) = write_legacy_native_http(
server.address(),
"POST",
&messages,
Some("Bearer alpha"),
&tool_body,
false,
);
assert_legacy_bearer_challenge(&wrong_post, "static token on custom POST /messages");
assert_eq!(
server.handler_call_snapshot().tool,
0,
"a rejected custom verifier token must not invoke the handler"
);
let (_tool_stream, tool_response) = write_legacy_native_http(
server.address(),
"POST",
&messages,
Some(matching.as_str()),
&tool_body,
false,
);
assert!(
tool_response.starts_with(b"HTTP/1.1 202") || tool_response.starts_with(b"HTTP/1.1 200"),
"the matching custom verifier token must admit tools/call: {}",
String::from_utf8_lossy(&tool_response)
);
let mut sse_body = opened;
let retained = read_legacy_sse_until(
&mut sse,
&mut sse_body,
&format!("subject:{PUBLIC_HTTP_CUSTOM_AUTH_SUBJECT}"),
);
assert!(
retained.contains(&format!("subject:{PUBLIC_HTTP_CUSTOM_AUTH_SUBJECT}")),
"admitted exact-2024 HTTP must commit the custom verifier subject: {retained}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"only the matching custom verifier token may invoke the handler"
);
drop(sse);
server.shutdown();
}
const PUBLIC_HTTP_LEGACY_WAIT_TOOL_NAME: &str = "public-http-e2e-legacy-wait";
/// Cooperative wait tool so an in-flight exact-2024 HTTP cancel can be observed.
struct PublicHttpLegacyWaitTool {
started: Arc<AtomicBool>,
observed_cancellation: Arc<AtomicBool>,
}
impl ToolHandler for PublicHttpLegacyWaitTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_HTTP_LEGACY_WAIT_TOOL_NAME.to_owned(),
description: Some("Waits until cancelled or the bound elapses".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
if ctx.is_cancelled() || ctx.checkpoint().is_err() {
self.observed_cancellation.store(true, Ordering::Release);
return Err(McpError::request_cancelled());
}
Ok(vec![Content::text("waited")])
}
fn call_async_in_request<'a>(
&'a self,
ctx: &'a McpContext,
request_cx: &'a Cx,
_arguments: serde_json::Value,
) -> BoxFuture<'a, McpOutcome<Vec<Content>>> {
Box::pin(async move {
match self.wait_until_cancelled(ctx, request_cx).await {
Outcome::Ok(()) => Outcome::Ok(vec![Content::text("waited")]),
Outcome::Err(error) => Outcome::Err(error),
Outcome::Cancelled(cancelled) => Outcome::Cancelled(cancelled),
Outcome::Panicked(panic) => Outcome::Panicked(panic),
}
})
}
fn call_final_outcome_async_in_request<'a>(
&'a self,
ctx: &'a McpContext,
request_cx: &'a Cx,
_arguments: serde_json::Value,
) -> BoxFuture<'a, McpOutcome<FinalToolOutcome>> {
Box::pin(async move {
match self.wait_until_cancelled(ctx, request_cx).await {
Outcome::Ok(()) => Outcome::Ok(FinalToolOutcome::Complete(CompleteResult::new(
FinalCallToolResult {
content: vec![ContentBlock::Text {
text: "waited".to_owned(),
annotations: None,
meta: None,
additional: BTreeMap::new(),
}],
is_error: false,
structured_content: None,
},
ResultMeta::empty(),
))),
Outcome::Err(error) => Outcome::Err(error),
Outcome::Cancelled(cancelled) => Outcome::Cancelled(cancelled),
Outcome::Panicked(panic) => Outcome::Panicked(panic),
}
})
}
}
impl PublicHttpLegacyWaitTool {
async fn wait_until_cancelled(&self, ctx: &McpContext, request_cx: &Cx) -> McpOutcome<()> {
self.started.store(true, Ordering::Release);
// Stay well past the HTTP operation bound unless cancel arrives.
for _ in 0..500 {
if ctx.is_cancelled() || ctx.checkpoint().is_err() || request_cx.checkpoint().is_err() {
self.observed_cancellation.store(true, Ordering::Release);
return Outcome::Err(McpError::request_cancelled());
}
asupersync::time::sleep(request_cx.now(), Duration::from_millis(10)).await;
}
Outcome::Ok(())
}
}
fn spawn_legacy_cancel_http_server() -> (HttpServerFixture, Arc<AtomicBool>, Arc<AtomicBool>) {
let started = Arc::new(AtomicBool::new(false));
let observed_cancellation = Arc::new(AtomicBool::new(false));
let wait_started = Arc::clone(&started);
let wait_observed = Arc::clone(&observed_cancellation);
let server = spawn_legacy_http_server("legacy cancel", move || {
ServerBuilder::new("facade-http-legacy-cancel", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpLegacyWaitTool {
started: wait_started,
observed_cancellation: wait_observed,
})
.tool(PublicHttpFastTool)
.build()
});
(server, started, observed_cancellation)
}
#[test]
fn e2e_public_http_legacy_cancel_stops_in_flight_tools_call_and_peer_stays_admitted() {
let (server, started, observed_cancellation) = spawn_legacy_cancel_http_server();
let (mut sse, opened, messages) =
initialize_legacy_native_session(server.address(), None, "e2e-public-http-legacy-cancel");
let tool = JsonRpcRequest::new(
"tools/call",
Some(json!({
"name": PUBLIC_HTTP_LEGACY_WAIT_TOOL_NAME,
"arguments": {},
})),
2_i64,
);
let tool_body = serde_json::to_vec(&tool).expect("exact-2024 wait tools/call serializes");
let address = server.address();
let messages_for_call = messages.clone();
let call = thread::spawn(move || {
write_legacy_native_http(address, "POST", &messages_for_call, None, &tool_body, false)
});
let start_deadline = Instant::now() + HTTP_OPERATION_BOUND;
while !started.load(Ordering::Acquire) && Instant::now() < start_deadline {
thread::sleep(Duration::from_millis(5));
}
assert!(
started.load(Ordering::Acquire),
"the exact-2024 wait tool must start before notifications/cancelled is posted"
);
let cancel = json!({
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": {
"requestId": 2,
"reason": "e2e-legacy-http-cancel",
},
});
let cancel_body = serde_json::to_vec(&cancel).expect("exact-2024 cancel serializes");
let (_cancel_stream, cancel_response) = write_legacy_native_http(
server.address(),
"POST",
&messages,
None,
&cancel_body,
false,
);
assert!(
cancel_response.starts_with(b"HTTP/1.1 202")
|| cancel_response.starts_with(b"HTTP/1.1 200"),
"notifications/cancelled must be admitted without waiting for the handler: {}",
String::from_utf8_lossy(&cancel_response)
);
let (_call_stream, call_response) = call.join().expect("in-flight tools/call thread joins");
assert!(
call_response.starts_with(b"HTTP/1.1 202") || call_response.starts_with(b"HTTP/1.1 200"),
"the cancelled tools/call POST must still complete its HTTP envelope: {}",
String::from_utf8_lossy(&call_response)
);
assert!(
observed_cancellation.load(Ordering::Acquire),
"notifications/cancelled must reach the in-flight exact-2024 wait handler"
);
let mut sse_body = opened;
let retained = drain_legacy_sse(&mut sse, &mut sse_body, Duration::from_millis(150));
assert!(
!retained.contains("waited"),
"a cancelled wait tool must not publish its completion text: {retained}"
);
assert!(
!retained.contains("\"id\":2"),
"exact MCP 2024-11-05 suppresses the JSON-RPC result of a cancelled request: {retained}"
);
drop(sse);
let cx = Cx::for_request();
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-cancel-peer");
let peer = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_FAST_TOOL_NAME,
json!({}),
"exact-2024 peer tools/call after cancel",
)
.expect("a peer tools/call must stay admitted after cancellation");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("fast"),
"changing only the cancelled request must not starve the peer: {peer:?}"
);
drop(client);
server.shutdown();
}
fn spawn_legacy_duplicate_http_server(behavior: DuplicateBehavior) -> HttpServerFixture {
spawn_legacy_http_server("legacy duplicate", move || {
ServerBuilder::new("facade-http-legacy-duplicate", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.on_duplicate(behavior)
.tool(PublicHttpValue)
.tool(ReplacedPublicHttpValue)
.build()
})
}
fn spawn_legacy_cache_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let tool_calls = Arc::clone(&handler_calls);
let mut server = spawn_legacy_http_server("legacy cache", move || {
ServerBuilder::new("facade-http-legacy-cache", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.middleware(
caching::ResponseCachingMiddleware::new()
.include_tools(vec![PUBLIC_HTTP_TOOL_NAME.to_owned()]),
)
.tool(CountingPublicHttpValue {
counters: tool_calls,
})
.build()
});
server.handler_calls = handler_calls;
server
}
#[test]
fn e2e_public_http_legacy_on_duplicate_error_keeps_first_and_replace_installs_second() {
let cx = Cx::for_request();
let keep_server = spawn_legacy_duplicate_http_server(DuplicateBehavior::Error);
let mut keep_client = connect_legacy_http_client(
&cx,
keep_server.address(),
"e2e-public-http-legacy-duplicate-error",
);
let kept = legacy_http_call(
&cx,
&mut keep_client,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha"}),
"exact-2024 duplicate-error tools/call",
)
.expect("Error must keep the first registration callable");
assert_eq!(
legacy_http_tool_text(&kept).as_deref(),
Some("tool:alpha"),
"on_duplicate Error must keep the first handler: {kept:?}"
);
drop(keep_client);
keep_server.shutdown();
let replace_server = spawn_legacy_duplicate_http_server(DuplicateBehavior::Replace);
let mut replace_client = connect_legacy_http_client(
&cx,
replace_server.address(),
"e2e-public-http-legacy-duplicate-replace",
);
let replaced = legacy_http_call(
&cx,
&mut replace_client,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha"}),
"exact-2024 duplicate-replace tools/call",
)
.expect("Replace must install the second registration");
assert_eq!(
legacy_http_tool_text(&replaced).as_deref(),
Some("replaced:alpha"),
"changing only on_duplicate to Replace must reach the second handler: {replaced:?}"
);
drop(replace_client);
replace_server.shutdown();
}
#[test]
fn e2e_public_http_legacy_response_cache_hits_same_allowlisted_call_and_misses_changed_args() {
let cx = Cx::for_request();
let server = spawn_legacy_cache_http_server();
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-cache");
let first = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha"}),
"exact-2024 first cached tools/call",
)
.expect("the first allowlisted tools/call must miss and reach the handler");
assert_eq!(
legacy_http_tool_text(&first).as_deref(),
Some("tool:alpha"),
"the first live tools/call must return the handler result: {first:?}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"the first allowlisted tools/call must invoke the counting handler"
);
let cached = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha"}),
"exact-2024 cached tools/call",
)
.expect("the second identical allowlisted tools/call must be a cache hit");
assert_eq!(
legacy_http_tool_text(&cached).as_deref(),
Some("tool:alpha"),
"the cache hit must keep the first complete result: {cached:?}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"the second identical tools/call must not re-invoke the counting handler"
);
let missed = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "beta"}),
"exact-2024 cache-miss tools/call",
)
.expect("changing only the arguments must miss the cache");
assert_eq!(
legacy_http_tool_text(&missed).as_deref(),
Some("tool:beta"),
"the cache miss must reach the handler with the new arguments: {missed:?}"
);
assert_eq!(
server.handler_call_snapshot().tool,
2,
"changing only the arguments must increment the counting handler"
);
drop(client);
server.shutdown();
}
fn spawn_legacy_transform_rename_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy transform rename", || {
ServerBuilder::new("facade-http-legacy-transform-rename", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(
transform::TransformedTool::from_tool(PublicHttpValue)
.name(PUBLIC_HTTP_TRANSFORMED_TOOL_NAME)
.rename_arg("value", "query")
.build(),
)
.build()
})
}
fn spawn_legacy_transform_hide_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy transform hide", || {
ServerBuilder::new("facade-http-legacy-transform-hide", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(
transform::TransformedTool::from_tool(PublicHttpValue)
.name(PUBLIC_HTTP_HIDDEN_TOOL_NAME)
.hide_arg("value", "hidden-default")
.build(),
)
.build()
})
}
#[test]
fn e2e_public_http_legacy_transformed_tool_renames_argument_and_keeps_parent_unknown() {
let cx = Cx::for_request();
let server = spawn_legacy_transform_rename_http_server();
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-transform");
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP transformed tools/list",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("live exact-2024 HTTP must list the renamed tool");
let renamed = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_TRANSFORMED_TOOL_NAME)
.unwrap_or_else(|| {
panic!("the transformed catalog must advertise the new name: {listed:?}")
});
let schema =
serde_json::to_string(&renamed.input_schema).expect("the renamed input schema serializes");
assert!(
schema.contains("\"query\""),
"the transformed schema must advertise the renamed argument: {schema}"
);
assert!(
!schema.contains("\"value\""),
"the transformed schema must drop the parent argument name: {schema}"
);
let called = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_TRANSFORMED_TOOL_NAME,
json!({"query": "alpha"}),
"exact-2024 renamed tools/call",
)
.expect("the renamed argument must reach the parent handler");
assert_eq!(
legacy_http_tool_text(&called).as_deref(),
Some("tool:alpha"),
"rename_arg must rewrite query back to the parent value: {called:?}"
);
let original = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha"}),
"exact-2024 parent name after rename",
)
.expect_err("the parent tool name must stay unadvertised");
let original = format!("{original:?}");
assert!(
original.contains("Unknown tool")
|| original.contains("Method not found")
|| original.contains("MethodNotFound"),
"calling the pre-transform name must stay unknown: {original}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_transformed_tool_hides_argument_and_injects_default() {
let cx = Cx::for_request();
let server = spawn_legacy_transform_hide_http_server();
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-hide-arg");
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP hide-arg tools/list",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("live exact-2024 HTTP must list the hide-arg tool");
let hidden = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_HIDDEN_TOOL_NAME)
.unwrap_or_else(|| panic!("the hide-arg catalog must advertise the new name: {listed:?}"));
let schema =
serde_json::to_string(&hidden.input_schema).expect("the hide-arg input schema serializes");
assert!(
!schema.contains("\"value\""),
"the hide-arg schema must drop the hidden argument: {schema}"
);
let injected = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_HIDDEN_TOOL_NAME,
json!({}),
"exact-2024 hide-arg tools/call",
)
.expect("the hide-arg tools/call must inject the configured default");
assert_eq!(
legacy_http_tool_text(&injected).as_deref(),
Some("tool:hidden-default"),
"the hidden default must reach the parent handler: {injected:?}"
);
let original = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha"}),
"exact-2024 parent name after hide-arg",
)
.expect_err("the parent tool name must stay unadvertised");
let original = format!("{original:?}");
assert!(
original.contains("Unknown tool")
|| original.contains("Method not found")
|| original.contains("MethodNotFound"),
"calling the pre-transform name must stay unknown: {original}"
);
drop(client);
server.shutdown();
}
const PUBLIC_HTTP_LEGACY_MOUNTED_TOOL_NAME: &str = "child/public-http-e2e-tool";
const PUBLIC_HTTP_LEGACY_MOUNTED_PROMPT_NAME: &str = "child/public-http-e2e-prompt";
const PUBLIC_HTTP_LEGACY_MOUNTED_RESOURCE_URI: &str = "child/test://public-http-e2e/resource";
fn spawn_legacy_mounted_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy mount", || {
let child = ServerBuilder::new("facade-http-legacy-mounted-child", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpValue)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.build();
ServerBuilder::new("facade-http-legacy-mounted-parent", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.mount(child, Some("child"))
.build()
})
}
#[test]
fn e2e_public_http_legacy_mount_prefixes_tool_prompt_and_resource() {
let cx = Cx::for_request();
let server = spawn_legacy_mounted_http_server();
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-mount");
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP mounted tools/list",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("live exact-2024 HTTP must list the prefixed child tool");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_LEGACY_MOUNTED_TOOL_NAME),
"legacy mount() must rewrite the child tool name: {listed:?}"
);
assert!(
!listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME),
"a nonempty mount prefix must not keep the unprefixed child tool: {listed:?}"
);
let called = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_LEGACY_MOUNTED_TOOL_NAME,
json!({ "value": PUBLIC_HTTP_TOOL_ARGUMENT }),
"exact-2024 mounted tools/call",
)
.expect("the prefixed child tool must dispatch");
assert_eq!(
legacy_http_tool_text(&called).as_deref(),
Some(PUBLIC_HTTP_TOOL_TEXT),
"the mounted live tool must retain the child handler value: {called:?}"
);
let listed_prompts = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP mounted prompts/list",
client.list_prompts(&cx, legacy_2024::ListPromptsParams::default()),
)
.expect("live exact-2024 HTTP must list the prefixed child prompt");
assert!(
listed_prompts
.prompts
.iter()
.any(|prompt| prompt.name == PUBLIC_HTTP_LEGACY_MOUNTED_PROMPT_NAME),
"legacy mount() must rewrite the child prompt name: {listed_prompts:?}"
);
let prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP mounted prompts/get",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: PUBLIC_HTTP_LEGACY_MOUNTED_PROMPT_NAME.to_owned(),
arguments: Some(HashMap::from([(
"subject".to_owned(),
PUBLIC_HTTP_TOOL_ARGUMENT.to_owned(),
)])),
meta: None,
},
),
)
.expect("the prefixed child prompt must dispatch");
let prompt = serde_json::to_value(prompt)
.expect("the mounted prompt result serializes for its observable assertion");
assert_eq!(
prompt["messages"][0]["content"]["text"],
json!(PUBLIC_HTTP_PROMPT_TEXT),
"the mounted live prompt must retain the child handler value: {prompt:?}"
);
let listed_resources = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP mounted resources/list",
client.list_resources(&cx, legacy_2024::ListResourcesParams::default()),
)
.expect("live exact-2024 HTTP must list the prefixed child resource");
assert!(
listed_resources
.resources
.iter()
.any(|resource| resource.uri == PUBLIC_HTTP_LEGACY_MOUNTED_RESOURCE_URI),
"legacy mount() must prefix every resource key: {listed_resources:?}"
);
assert!(
!listed_resources
.resources
.iter()
.any(|resource| resource.uri == PUBLIC_HTTP_RESOURCE_URI),
"legacy mount() must not keep the unprefixed child resource URI: {listed_resources:?}"
);
let resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP mounted resources/read",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_LEGACY_MOUNTED_RESOURCE_URI.to_owned(),
meta: None,
},
),
)
.expect("the prefixed child resource must dispatch");
let resource_text = serde_json::to_value(&resource)
.ok()
.and_then(|value| value["contents"][0]["text"].as_str().map(str::to_owned));
assert_eq!(
resource_text.as_deref(),
Some(PUBLIC_HTTP_RESOURCE_TEXT),
"the mounted live resource must retain the child handler value: {resource:?}"
);
let original = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_TOOL_NAME,
json!({ "value": PUBLIC_HTTP_TOOL_ARGUMENT }),
"exact-2024 unprefixed tool after mount",
)
.expect_err("the unprefixed child tool name must stay unknown");
let original = format!("{original:?}");
assert!(
original.contains("Unknown tool")
|| original.contains("Method not found")
|| original.contains("MethodNotFound"),
"calling the unprefixed child name must stay unknown: {original}"
);
drop(client);
server.shutdown();
}
fn spawn_legacy_catalog_metadata_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy catalog metadata", || {
let icons =
vec![RawIcon::try_new(PUBLIC_HTTP_ICON_SRC).expect("the catalog icon source is valid")];
ServerBuilder::new("facade-http-legacy-catalog-metadata", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpIconTool { icons })
.tool(PublicHttpPlainTool)
.build()
})
}
#[test]
fn e2e_public_http_legacy_list_tools_retains_version_tags_and_annotations() {
let cx = Cx::for_request();
let server = spawn_legacy_catalog_metadata_http_server();
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-catalog");
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP catalog tools/list",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("live exact-2024 HTTP must list the catalog metadata tools");
let icon_tool = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_ICON_TOOL_NAME)
.expect("the annotated tool must remain on the live catalog");
let plain_tool = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_PLAIN_TOOL_NAME)
.expect("the plain peer tool must remain on the live catalog");
assert_eq!(icon_tool.version.as_deref(), Some("1.2.3"));
assert!(
icon_tool.tags.iter().any(|tag| tag == "catalog"),
"the annotated tool must retain its tags: {icon_tool:?}"
);
let annotations = icon_tool
.annotations
.as_ref()
.expect("the annotated tool must retain its projected annotations");
assert_eq!(annotations.read_only, Some(true));
assert_eq!(annotations.idempotent, Some(true));
assert_eq!(annotations.destructive, None);
assert_eq!(plain_tool.version, None);
assert!(plain_tool.tags.is_empty());
assert_eq!(plain_tool.annotations, None);
drop(client);
server.shutdown();
}
fn spawn_legacy_two_client_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy two-client", || {
ServerBuilder::new("facade-http-legacy-two-client", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpValue)
.build()
})
}
#[test]
fn e2e_public_http_legacy_two_clients_call_the_same_tool_independently() {
let cx = Cx::for_request();
let server = spawn_legacy_two_client_http_server();
let mut first =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-two-a");
let mut second =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-two-b");
let first_result = legacy_http_call(
&cx,
&mut first,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha"}),
"exact-2024 first client tools/call",
)
.expect("the first live exact-2024 HTTP client must reach the shared tool");
let second_result = legacy_http_call(
&cx,
&mut second,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "beta"}),
"exact-2024 second client tools/call",
)
.expect("the second live exact-2024 HTTP client must reach the shared tool independently");
assert_eq!(
legacy_http_tool_text(&first_result).as_deref(),
Some("tool:alpha"),
"the first client must retain its own handler result: {first_result:?}"
);
assert_eq!(
legacy_http_tool_text(&second_result).as_deref(),
Some("tool:beta"),
"changing only the peer arguments must not mix client results: {second_result:?}"
);
drop(first);
drop(second);
server.shutdown();
}
fn spawn_legacy_cursor_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy cursor", || {
ServerBuilder::new("facade-http-legacy-cursor", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.list_page_size(1)
.tool(PublicHttpValue)
.tool(PublicHttpCursorSecondary)
.tool(PublicHttpCursorOther)
.resource(PublicHttpSnapshotResource)
.resource(PublicHttpCursorResourceAResource)
.resource(PublicHttpCursorResourceBResource)
.prompt(PublicHttpCursorPromptAPrompt)
.prompt(PublicHttpCursorPromptBPrompt)
.build()
})
}
#[test]
fn e2e_public_http_legacy_tools_list_cursor_continues_and_rejects_query_or_kind_change() {
let cx = Cx::for_request();
let server = spawn_legacy_cursor_http_server();
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-cursor");
let first = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP first cursor tools/list",
client.list_tools(
&cx,
legacy_2024::ListToolsParams {
cursor: None,
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.expect("live exact-2024 HTTP must page the cursor-tagged tools");
assert_eq!(
first.tools.len(),
1,
"page size 1 must create a real continuation: {first:?}"
);
assert_eq!(
first.tools[0].name, PUBLIC_HTTP_TOOL_NAME,
"the first cursor page must retain the primary tagged tool: {first:?}"
);
let cursor = first
.next_cursor
.clone()
.expect("the first filtered page must carry an opaque cursor");
let second = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP cursor continuation tools/list",
client.list_tools(
&cx,
legacy_2024::ListToolsParams {
cursor: Some(cursor.clone()),
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.expect("the exact cursor/query continuation must reach the second live page");
assert_eq!(
second.tools.len(),
1,
"the continuation must retain one remaining tagged tool: {second:?}"
);
assert_eq!(
second.tools[0].name, PUBLIC_HTTP_CURSOR_SECONDARY_TOOL_NAME,
"the continuation must retain the second tagged tool: {second:?}"
);
assert!(
second.next_cursor.is_none(),
"the last cursor page must not invent another continuation: {second:?}"
);
let query_rejection = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP cursor query mismatch",
client.list_tools(
&cx,
legacy_2024::ListToolsParams {
cursor: Some(cursor.clone()),
include_tags: Some(vec!["other".to_owned()]),
exclude_tags: None,
},
),
)
.expect_err("changing only includeTags must reject the cursor before page projection");
let query_rejection = format!("{query_rejection:?}");
assert!(
query_rejection.contains("InvalidParams") || query_rejection.contains("invalid"),
"a query-mismatched cursor must stay InvalidParams: {query_rejection}"
);
let kind_rejection = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP cursor kind mismatch",
client.list_resources(
&cx,
legacy_2024::ListResourcesParams {
cursor: Some(cursor),
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.expect_err("changing only the catalog kind must reject the tools cursor");
let kind_rejection = format!("{kind_rejection:?}");
assert!(
kind_rejection.contains("InvalidParams") || kind_rejection.contains("invalid"),
"a tools cursor must not select a resources page: {kind_rejection}"
);
drop(client);
server.shutdown();
}
const PUBLIC_HTTP_CURSOR_RESOURCE_A_URI: &str = "test://public-http-e2e/cursor-a";
const PUBLIC_HTTP_CURSOR_RESOURCE_B_URI: &str = "test://public-http-e2e/cursor-b";
const PUBLIC_HTTP_CURSOR_PROMPT_A_NAME: &str = "public-http-e2e-cursor-prompt-a";
const PUBLIC_HTTP_CURSOR_PROMPT_B_NAME: &str = "public-http-e2e-cursor-prompt-b";
#[test]
fn e2e_public_http_legacy_resources_and_prompts_list_cursor_continues() {
let cx = Cx::for_request();
let server = spawn_legacy_cursor_http_server();
let mut client = connect_legacy_http_client(
&cx,
server.address(),
"e2e-public-http-legacy-catalog-cursor",
);
let first_resources = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP first cursor resources/list",
client.list_resources(
&cx,
legacy_2024::ListResourcesParams {
cursor: None,
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.expect("live exact-2024 HTTP must page the cursor-tagged resources");
assert_eq!(
first_resources.resources.len(),
1,
"page size 1 must create a real resource continuation: {first_resources:?}"
);
let first_resource_uri = first_resources.resources[0].uri.clone();
assert!(
first_resource_uri == PUBLIC_HTTP_CURSOR_RESOURCE_A_URI
|| first_resource_uri == PUBLIC_HTTP_CURSOR_RESOURCE_B_URI,
"the first resource page must retain a cursor-tagged URI: {first_resources:?}"
);
let resource_cursor = first_resources
.next_cursor
.clone()
.expect("the first filtered resource page must carry an opaque cursor");
let second_resources = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP cursor continuation resources/list",
client.list_resources(
&cx,
legacy_2024::ListResourcesParams {
cursor: Some(resource_cursor.clone()),
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.expect("the exact resource cursor continuation must reach the second live page");
assert_eq!(
second_resources.resources.len(),
1,
"the resource continuation must retain one remaining tagged URI: {second_resources:?}"
);
let second_resource_uri = second_resources.resources[0].uri.as_str();
assert_ne!(
second_resource_uri, first_resource_uri,
"the resource continuation must retain the other tagged URI: {second_resources:?}"
);
assert!(
second_resource_uri == PUBLIC_HTTP_CURSOR_RESOURCE_A_URI
|| second_resource_uri == PUBLIC_HTTP_CURSOR_RESOURCE_B_URI,
"the resource continuation must stay inside the cursor tag: {second_resources:?}"
);
assert!(
second_resources.next_cursor.is_none(),
"the last resource cursor page must not invent another continuation: {second_resources:?}"
);
let resource_kind_rejection = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP resource cursor kind mismatch",
client.list_prompts(
&cx,
legacy_2024::ListPromptsParams {
cursor: Some(resource_cursor),
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.expect_err("changing only the catalog kind must reject the resources cursor");
let resource_kind_rejection = format!("{resource_kind_rejection:?}");
assert!(
resource_kind_rejection.contains("InvalidParams")
|| resource_kind_rejection.contains("invalid"),
"a resources cursor must not select a prompts page: {resource_kind_rejection}"
);
let first_prompts = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP first cursor prompts/list",
client.list_prompts(
&cx,
legacy_2024::ListPromptsParams {
cursor: None,
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.expect("live exact-2024 HTTP must page the cursor-tagged prompts");
assert_eq!(
first_prompts.prompts.len(),
1,
"page size 1 must create a real prompt continuation: {first_prompts:?}"
);
let first_prompt_name = first_prompts.prompts[0].name.clone();
assert!(
first_prompt_name == PUBLIC_HTTP_CURSOR_PROMPT_A_NAME
|| first_prompt_name == PUBLIC_HTTP_CURSOR_PROMPT_B_NAME,
"the first prompt page must retain a cursor-tagged name: {first_prompts:?}"
);
let prompt_cursor = first_prompts
.next_cursor
.clone()
.expect("the first filtered prompt page must carry an opaque cursor");
let second_prompts = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP cursor continuation prompts/list",
client.list_prompts(
&cx,
legacy_2024::ListPromptsParams {
cursor: Some(prompt_cursor.clone()),
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.expect("the exact prompt cursor continuation must reach the second live page");
assert_eq!(
second_prompts.prompts.len(),
1,
"the prompt continuation must retain one remaining tagged name: {second_prompts:?}"
);
let second_prompt_name = second_prompts.prompts[0].name.as_str();
assert_ne!(
second_prompt_name, first_prompt_name,
"the prompt continuation must retain the other tagged name: {second_prompts:?}"
);
assert!(
second_prompt_name == PUBLIC_HTTP_CURSOR_PROMPT_A_NAME
|| second_prompt_name == PUBLIC_HTTP_CURSOR_PROMPT_B_NAME,
"the prompt continuation must stay inside the cursor tag: {second_prompts:?}"
);
assert!(
second_prompts.next_cursor.is_none(),
"the last prompt cursor page must not invent another continuation: {second_prompts:?}"
);
let prompt_kind_rejection = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP prompt cursor kind mismatch",
client.list_tools(
&cx,
legacy_2024::ListToolsParams {
cursor: Some(prompt_cursor),
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.expect_err("changing only the catalog kind must reject the prompts cursor");
let prompt_kind_rejection = format!("{prompt_kind_rejection:?}");
assert!(
prompt_kind_rejection.contains("InvalidParams")
|| prompt_kind_rejection.contains("invalid"),
"a prompts cursor must not select a tools page: {prompt_kind_rejection}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let server = spawn_legacy_compose_http_server();
let mut client =
connect_legacy_http_client(&cx, server.address(), "e2e-public-http-legacy-compose");
let composed = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_COMPOSE_TOOL_NAME,
json!({"value": "alpha"}),
"exact-2024 HTTP compose tools/call",
)
.expect("live exact-2024 HTTP must compose a nested tool call and resource read");
assert_eq!(
legacy_http_tool_text(&composed).as_deref(),
Some("compose:tool:alpha|resource:deterministic"),
"exact-2024 HTTP dispatch must attach request-scoped callers: {composed:?}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"the nested tools/call must invoke the peer tool exactly once"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"the nested resources/read must invoke the peer resource exactly once"
);
let missing = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_COMPOSE_TOOL_NAME,
json!({"value": "alpha", "tool": "public-http-e2e-missing"}),
"exact-2024 HTTP compose missing nested tool",
);
let missing = match missing {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing.contains("public-http-e2e-missing")
|| missing.contains("compose-nested-tool")
|| missing.contains("Unknown tool")
|| missing.contains("not found"),
"the nested unknown tool must stay a handler-visible refusal: {missing}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"an unknown nested tool name must not invoke the peer tool"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_prompt_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let server = spawn_legacy_compose_http_server();
let mut client = connect_legacy_http_client(
&cx,
server.address(),
"e2e-public-http-legacy-prompt-compose",
);
let prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP compose prompts/get",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: PUBLIC_HTTP_COMPOSE_PROMPT_NAME.to_owned(),
arguments: Some(HashMap::from([("value".to_owned(), "alpha".to_owned())])),
meta: None,
},
),
)
.expect(
"live exact-2024 HTTP must compose a nested tool call and resource read from prompts/get",
);
let prompt =
serde_json::to_value(prompt).expect("the exact-2024 HTTP prompt result serializes");
assert_eq!(
prompt["messages"][0]["content"]["text"],
json!("compose:tool:alpha|resource:deterministic"),
"exact-2024 HTTP prompt dispatch must attach request-scoped callers: {prompt}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"the nested tools/call must invoke the peer tool exactly once"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"the nested resources/read must invoke the peer resource exactly once"
);
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP compose missing nested tool from prompts/get",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: PUBLIC_HTTP_COMPOSE_PROMPT_NAME.to_owned(),
arguments: Some(HashMap::from([
("value".to_owned(), "alpha".to_owned()),
("tool".to_owned(), "public-http-e2e-missing".to_owned()),
])),
meta: None,
},
),
);
let missing = match missing {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing.contains("public-http-e2e-missing")
|| missing.contains("compose-nested-tool")
|| missing.contains("Unknown tool")
|| missing.contains("not found"),
"the nested unknown tool must stay a handler-visible refusal: {missing}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"an unknown nested tool name must not invoke the peer tool"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_legacy_resource_composes_nested_tool_and_resource() {
let cx = Cx::for_request();
let server = spawn_legacy_compose_http_server();
let mut client = connect_legacy_http_client(
&cx,
server.address(),
"e2e-public-http-legacy-resource-compose",
);
let resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP compose resources/read",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_COMPOSE_RESOURCE_URI.to_owned(),
meta: None,
},
),
)
.expect(
"live exact-2024 HTTP must compose a nested tool call and resource read from resources/read",
);
let resource =
serde_json::to_value(resource).expect("the exact-2024 HTTP resource result serializes");
assert_eq!(
resource["contents"][0]["text"],
json!("compose:tool:alpha|resource:deterministic"),
"exact-2024 HTTP resource dispatch must attach request-scoped callers: {resource}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"the nested tools/call must invoke the peer tool exactly once"
);
assert_eq!(
server.handler_call_snapshot().resource,
1,
"the nested resources/read must invoke the peer resource exactly once"
);
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP compose missing nested tool from resources/read",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_COMPOSE_MISSING_TOOL_RESOURCE_URI.to_owned(),
meta: None,
},
),
);
let missing = match missing {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing.contains("public-http-e2e-missing")
|| missing.contains("compose-nested-tool")
|| missing.contains("Unknown tool")
|| missing.contains("not found"),
"the nested unknown tool must stay a handler-visible refusal: {missing}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"an unknown nested tool name must not invoke the peer tool"
);
drop(client);
server.shutdown();
}
#[cfg(feature = "proxy")]
const PUBLIC_HTTP_LEGACY_PROXY_TOOL_NAME: &str = "child/public-http-e2e-tool";
#[cfg(feature = "proxy")]
const PUBLIC_HTTP_LEGACY_PROXY_PROMPT_NAME: &str = "child/public-http-e2e-prompt";
#[cfg(feature = "proxy")]
const PUBLIC_HTTP_LEGACY_PROXY_RESOURCE_URI: &str = "child/test://public-http-e2e/resource";
#[cfg(feature = "proxy")]
fn spawn_legacy_upstream_http_server() -> HttpServerFixture {
spawn_legacy_http_server("legacy as_proxy upstream", || {
ServerBuilder::new("facade-http-legacy-proxy-upstream", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpValue)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.build()
})
}
fn spawn_legacy_upstream_http_server_with_instructions() -> HttpServerFixture {
spawn_legacy_http_server("legacy as_proxy instructed upstream", || {
ServerBuilder::new("facade-http-legacy-proxy-instructed-upstream", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.instructions(PUBLIC_HTTP_INSTRUCTIONS)
.tool(PublicHttpValue)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.build()
})
}
fn spawn_legacy_as_proxy_template_completion_upstream() -> HttpServerFixture {
spawn_legacy_http_server("legacy as_proxy template-completion upstream", || {
ServerBuilder::new("facade-http-legacy-proxy-template-complete", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpValue)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.resource(PublicHttpCursorTemplateResource)
.resource(PublicHttpOtherTemplateResource)
.legacy_resource_template_completion_handler(
PUBLIC_HTTP_CURSOR_TEMPLATE,
PublicHttpTemplateCompletion,
)
.build()
})
}
/// Live counting tool plus the catalog members required by as_proxy cache proofs.
#[cfg(all(feature = "proxy", feature = "tasks"))]
fn spawn_modern_as_proxy_counting_upstream() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let tool_calls = Arc::clone(&handler_calls);
let mut fixture = spawn_legacy_http_server("modern as_proxy counting upstream", move || {
ServerBuilder::new("facade-http-as-proxy-count", "1.0.0")
.protocol_policy(ProtocolPolicy::ModernOnly)
.expect("ModernOnly is available")
.tool(CountingPublicHttpValue {
counters: tool_calls,
})
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.build()
});
fixture.handler_calls = handler_calls;
fixture
}
/// Live exact-2024 counting tool plus the catalog members required by
/// `spawn_legacy_as_proxy_http_gateway`.
#[cfg(feature = "proxy")]
fn spawn_legacy_as_proxy_counting_upstream() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let tool_calls = Arc::clone(&handler_calls);
let mut fixture = spawn_legacy_http_server("legacy as_proxy counting upstream", move || {
ServerBuilder::new("facade-http-legacy-proxy-count", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(CountingPublicHttpValue {
counters: tool_calls,
})
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.build()
});
fixture.handler_calls = handler_calls;
fixture
}
/// Live modern session-state tool plus the catalog members required by as_proxy.
#[cfg(all(feature = "proxy", feature = "tasks"))]
fn spawn_modern_as_proxy_state_upstream() -> HttpServerFixture {
spawn_legacy_http_server("modern as_proxy state upstream", || {
ServerBuilder::new("facade-http-as-proxy-state", "1.0.0")
.protocol_policy(ProtocolPolicy::ModernOnly)
.expect("ModernOnly is available")
.tool(PublicHttpStateTool)
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.build()
})
}
/// Live exact-2024 session-state tool plus the catalog members required by
/// `spawn_legacy_as_proxy_http_gateway_configured`.
#[cfg(feature = "proxy")]
fn spawn_legacy_as_proxy_state_upstream() -> HttpServerFixture {
spawn_legacy_http_server("legacy as_proxy state upstream", || {
ServerBuilder::new("facade-http-legacy-proxy-state", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpStateTool)
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.build()
})
}
/// Live transformed tool plus the catalog members required by as_proxy prefix proofs.
#[cfg(all(feature = "proxy", feature = "tasks"))]
fn spawn_modern_as_proxy_transform_upstream() -> HttpServerFixture {
spawn_legacy_http_server("modern as_proxy transform upstream", || {
ServerBuilder::new("facade-http-as-proxy-transform", "1.0.0")
.protocol_policy(ProtocolPolicy::ModernOnly)
.expect("ModernOnly is available")
.tool(
transform::TransformedTool::from_tool(PublicHttpValue)
.name(PUBLIC_HTTP_TRANSFORMED_TOOL_NAME)
.rename_arg("value", "query")
.build(),
)
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.build()
})
}
/// Live exact-2024 transformed tool plus the catalog members required by
/// `spawn_legacy_as_proxy_http_gateway_configured`.
#[cfg(feature = "proxy")]
fn spawn_legacy_as_proxy_transform_upstream() -> HttpServerFixture {
spawn_legacy_http_server("legacy as_proxy transform upstream", || {
ServerBuilder::new("facade-http-legacy-proxy-transform", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(
transform::TransformedTool::from_tool(PublicHttpValue)
.name(PUBLIC_HTTP_TRANSFORMED_TOOL_NAME)
.rename_arg("value", "query")
.build(),
)
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.build()
})
}
/// Live handler-panic plus fast-peer catalog for as_proxy sanitization proofs.
#[cfg(all(feature = "proxy", feature = "tasks"))]
fn spawn_modern_as_proxy_panic_upstream() -> HttpServerFixture {
spawn_legacy_http_server("modern as_proxy panic upstream", || {
ServerBuilder::new("facade-http-as-proxy-panic", "1.0.0")
.protocol_policy(ProtocolPolicy::ModernOnly)
.expect("ModernOnly is available")
.tool(PublicHttpPanicTool)
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.prompt(PublicHttpPanicPrompt)
.resource(PublicHttpSnapshotResource)
.resource(PublicHttpPanicResource)
.completion_handler(PublicHttpPanicCompletion)
.build()
})
}
/// Live exact-2024 handler-panic plus the catalog members required by
/// `spawn_legacy_as_proxy_http_gateway`.
#[cfg(feature = "proxy")]
fn spawn_legacy_as_proxy_panic_upstream() -> HttpServerFixture {
spawn_legacy_http_server("legacy as_proxy panic upstream", || {
ServerBuilder::new("facade-http-legacy-proxy-panic", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpValue)
.tool(PublicHttpPanicTool)
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.prompt(PublicHttpPanicPrompt)
.resource(PublicHttpSnapshotResource)
.resource(PublicHttpPanicResource)
.legacy_completion_handler(PublicHttpPanicCompletion)
.build()
})
}
/// Live exact-2024 completion provider that reports progress, plus the catalog
/// members required by `spawn_legacy_as_proxy_http_gateway`.
#[cfg(feature = "proxy")]
fn spawn_legacy_as_proxy_completion_progress_upstream() -> HttpServerFixture {
spawn_legacy_http_server("legacy as_proxy completion-progress upstream", || {
let counters = Arc::new(PublicHttpHandlerCallCounters::default());
ServerBuilder::new("facade-http-legacy-proxy-complete-progress", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpValue)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.legacy_completion_handler(CountingPublicHttpCompletion { counters })
.build()
})
}
/// Live exact-2024 FilesystemProvider plus the catalog members required by
/// `spawn_legacy_as_proxy_http_gateway`. Prefixed `child/file:///e2e/{+path}`
/// proofs use this so as_proxy rewrites onto the upstream file URI.
#[cfg(all(feature = "proxy", any(target_os = "linux", target_os = "macos")))]
fn spawn_legacy_as_proxy_filesystem_upstream() -> HttpServerFixture {
spawn_legacy_http_server("legacy as_proxy filesystem upstream", || {
let root = std::env::temp_dir().join(format!(
"fastmcp-public-http-legacy-as-proxy-fs-e2e-{}",
std::process::id()
));
std::fs::create_dir_all(&root).expect("the legacy as_proxy filesystem e2e root is created");
std::fs::write(
root.join(PUBLIC_HTTP_FS_FILE_NAME),
PUBLIC_HTTP_FS_FILE_TEXT,
)
.expect("the legacy as_proxy filesystem e2e file is written");
let handler = providers::FilesystemProvider::new(&root)
.with_prefix(PUBLIC_HTTP_FS_PREFIX)
.with_exclude(&[])
.build()
.expect("FilesystemProvider::build must succeed for the exact-2024 as_proxy proof");
ServerBuilder::new("facade-http-legacy-proxy-filesystem", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpValue)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.resource(handler)
.build()
})
}
/// Live exact-2024 stdio `as_proxy("ext")` HTTP gateway against the shipped
/// echo process launched as LegacyOnly. Prefixed resource-template completion
/// proofs use this so `ext/note://{name}` rewrites onto the echo note provider.
#[cfg(all(unix, feature = "proxy"))]
fn spawn_legacy_http_stdio_as_proxy_gateway() -> HttpServerFixture {
spawn_legacy_http_stdio_as_proxy_gateway_with_env(Vec::new())
}
/// Same LegacyOnly stdio as_proxy gateway with extra echo-process env
/// (FilesystemProvider proofs pass `FASTMCP_FS_ROOT` / `FASTMCP_FS_PREFIX`).
#[cfg(all(unix, feature = "proxy"))]
fn spawn_legacy_http_stdio_as_proxy_gateway_with_env(
extra_env: Vec<(String, String)>,
) -> HttpServerFixture {
spawn_legacy_http_stdio_as_proxy_gateway_with_options(
extra_env, false, None, None, false, false, false, false, None,
)
}
#[cfg(all(unix, feature = "proxy"))]
fn spawn_legacy_http_stdio_as_proxy_gateway_with_options(
extra_env: Vec<(String, String)>,
mask_error_details: bool,
request_timeout_secs: Option<u64>,
cache_tools: Option<Vec<String>>,
rate_limit: bool,
sliding_window: bool,
static_token: bool,
strict_input: bool,
list_page_size: Option<usize>,
) -> HttpServerFixture {
spawn_legacy_http_stdio_as_proxy_gateway_with_duplicate(
extra_env,
mask_error_details,
request_timeout_secs,
cache_tools,
rate_limit,
sliding_window,
static_token,
strict_input,
list_page_size,
None,
)
}
#[cfg(all(unix, feature = "proxy"))]
fn spawn_legacy_http_stdio_as_proxy_gateway_with_duplicate(
extra_env: Vec<(String, String)>,
mask_error_details: bool,
request_timeout_secs: Option<u64>,
cache_tools: Option<Vec<String>>,
rate_limit: bool,
sliding_window: bool,
static_token: bool,
strict_input: bool,
list_page_size: Option<usize>,
on_duplicate: Option<DuplicateBehavior>,
) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("legacy stdio as_proxy gateway HTTP server reactor initializes"),
)
.build()
.expect("legacy stdio as_proxy gateway HTTP server installs an owned runtime");
let outcome = runtime.block_on(async move {
let cx =
Cx::current().expect("owned gateway runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err(
"legacy stdio as_proxy gateway HTTP server control receiver went away"
.to_owned(),
);
}
let mut last_connect_error = None;
let mut stdio = None;
for attempt in 1_u32..=4 {
let mut builder = legacy_2024::ClientBuilder::new()
.client_info("e2e-http-legacy-stdio-as-proxy-upstream", "1.0.0")
.env("FASTMCP_PROTOCOL_POLICY", "legacy-only")
.forward_inbound_legacy_reverse();
for (key, value) in &extra_env {
builder = builder.env(key.clone(), value.clone());
}
match builder
.max_retries(2)
.retry_delay_ms(150)
.connect_stdio_with_cx(env!("CARGO_BIN_EXE_echo_server"), &[], &cx)
{
Ok(client) => {
stdio = Some(client);
break;
}
Err(error) => {
last_connect_error = Some(error);
if attempt < 4 {
thread::sleep(Duration::from_millis(50 * u64::from(attempt)));
}
}
}
}
let mut stdio = stdio.ok_or_else(|| {
format!(
"live exact-2024 stdio as_proxy upstream connect failed: {}",
last_connect_error
.expect("a failed stdio connect records its last transport error")
)
})?;
// as_proxy catalog listing is the first stdio request on this
// path. A live tools/list first proves the echo process is
// answering before install consumes the idle deadline.
let _ = stdio.list_tools().map_err(|error| {
format!(
"live exact-2024 stdio as_proxy upstream must answer tools/list before install: {error}"
)
})?;
let mut builder =
legacy_2024::ServerBuilder::new("e2e-legacy-http-stdio-as-proxy", "1.0.0")
.mask_error_details(mask_error_details);
if let Some(request_timeout_secs) = request_timeout_secs {
builder = builder.request_timeout(request_timeout_secs);
}
if let Some(cache_tools) = cache_tools {
builder = builder.middleware(
caching::ResponseCachingMiddleware::new().include_tools(cache_tools),
);
}
if rate_limit {
builder = builder.middleware(
rate_limiting::RateLimitingMiddleware::new(1.0e-300).burst_capacity(1),
);
}
if sliding_window {
builder = builder.middleware(
rate_limiting::SlidingWindowRateLimitingMiddleware::new(1, 60),
);
}
if static_token {
let verifier = StaticTokenVerifier::new([(
"alpha",
AuthContext::with_subject(PUBLIC_HTTP_AUTH_SUBJECT),
)])
.expect("the deterministic exact-2024 stdio as_proxy gateway bearer verifier is valid")
.with_allowed_schemes(["Bearer"])
.expect("the exact-2024 stdio as_proxy gateway bearer scheme allowlist is valid");
builder = builder.auth_provider(TokenAuthProvider::new(verifier));
}
if strict_input {
builder = builder.strict_input_validation(true);
}
if let Some(page_size) = list_page_size {
builder = builder.list_page_size(page_size);
}
if let Some(behavior) = on_duplicate {
builder = builder.on_duplicate(behavior).tool(LocalPrefixedEcho);
}
let server = builder
.as_proxy("ext", stdio)
.map_err(|error| format!("legacy as_proxy stdio install failed: {error}"))?
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message =
format!("legacy stdio as_proxy gateway HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("legacy stdio as_proxy gateway HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err(
"legacy stdio as_proxy gateway HTTP server startup receiver went away"
.to_owned(),
);
}
bound.serve(&cx).await.map_err(|error| {
format!("legacy stdio as_proxy gateway HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("legacy stdio as_proxy gateway HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(ready)) => break ready,
Ok(Err(error)) => {
panic!("legacy stdio as_proxy gateway HTTP server failed to start: {error}")
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("legacy stdio as_proxy gateway HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[cfg(feature = "proxy")]
fn spawn_legacy_as_proxy_http_gateway(upstream: SocketAddr) -> HttpServerFixture {
spawn_legacy_as_proxy_http_gateway_configured(
upstream,
None,
false,
PUBLIC_HTTP_TOOL_NAME,
false,
false,
false,
None,
)
}
#[cfg(feature = "proxy")]
fn spawn_legacy_as_proxy_http_gateway_configured(
upstream: SocketAddr,
cache_tools: Option<Vec<String>>,
rate_limit: bool,
required_tool_name: &'static str,
sliding_window: bool,
static_token: bool,
strict_input: bool,
list_page_size: Option<usize>,
) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("legacy as_proxy gateway HTTP server reactor initializes"),
)
.build()
.expect("legacy as_proxy gateway HTTP server installs an owned runtime");
let outcome = runtime.block_on(async move {
let cx =
Cx::current().expect("owned gateway runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("legacy as_proxy gateway HTTP server control receiver went away".to_owned());
}
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream, "/sse")),
Some(public_http_target(upstream, "/messages")),
"e2e-legacy-http-proxy-gateway".to_owned(),
"e2e-legacy-http-proxy-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.map_err(|error| format!("legacy as_proxy HTTP plan failed: {error}"))?;
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-live-upstream",
"native-h1:e2e-legacy-live-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-http-proxy".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities {
sampling: Some(Default::default()),
elicitation: None,
roots: Some(legacy_2024::RootsCapability {
list_changed: false,
}),
},
cx.clone(),
)
.map_err(|error| format!("live exact-2024 HTTP proxy upstream connect failed: {error}"))?;
let catalog = proxy
.catalog_typed()
.map_err(|error| format!("live exact-2024 HTTP proxy catalog failed: {error}"))?;
let ProxyToolCatalog::Legacy(tools) = &catalog.tools else {
return Err(
"exact-2024 HTTP as_proxy catalog must stay on the 2024 tool model".to_owned(),
);
};
if !tools.iter().any(|tool| tool.name == required_tool_name) {
return Err(format!(
"live exact-2024 HTTP proxy catalog omitted {required_tool_name}: {tools:?}"
));
}
let ProxyPromptCatalog::Legacy(prompts) = &catalog.prompts else {
return Err(
"exact-2024 HTTP as_proxy catalog must stay on the 2024 prompt model".to_owned(),
);
};
if !prompts
.iter()
.any(|prompt| prompt.name == PUBLIC_HTTP_PROMPT_NAME)
{
return Err(format!(
"live exact-2024 HTTP proxy catalog omitted {PUBLIC_HTTP_PROMPT_NAME}: {prompts:?}"
));
}
let ProxyResourceCatalog::Legacy(resources) = &catalog.resources else {
return Err(
"exact-2024 HTTP as_proxy catalog must stay on the 2024 resource model"
.to_owned(),
);
};
if !resources
.iter()
.any(|resource| resource.uri == PUBLIC_HTTP_RESOURCE_URI)
{
return Err(format!(
"live exact-2024 HTTP proxy catalog omitted {PUBLIC_HTTP_RESOURCE_URI}: {resources:?}"
));
}
let mut builder = ServerBuilder::new("e2e-legacy-http-gateway", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available");
if let Some(cache_tools) = cache_tools {
builder = builder.middleware(
caching::ResponseCachingMiddleware::new().include_tools(cache_tools),
);
}
if rate_limit {
builder = builder.middleware(
rate_limiting::RateLimitingMiddleware::new(1.0e-300).burst_capacity(1),
);
}
if sliding_window {
builder = builder.middleware(
rate_limiting::SlidingWindowRateLimitingMiddleware::new(1, 60),
);
}
if static_token {
let verifier = StaticTokenVerifier::new([(
"alpha",
AuthContext::with_subject(PUBLIC_HTTP_AUTH_SUBJECT),
)])
.expect("the deterministic exact-2024 as_proxy gateway bearer verifier is valid")
.with_allowed_schemes(["Bearer"])
.expect("the exact-2024 as_proxy gateway bearer scheme allowlist is valid");
builder = builder.auth_provider(TokenAuthProvider::new(verifier));
}
if strict_input {
builder = builder.strict_input_validation(true);
}
if let Some(page_size) = list_page_size {
builder = builder.list_page_size(page_size);
}
let server = builder
.as_proxy_typed("child", proxy, catalog)
.map_err(|error| format!("legacy as_proxy_typed install failed: {error}"))?
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("legacy as_proxy gateway HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("legacy as_proxy gateway HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err(
"legacy as_proxy gateway HTTP server startup receiver went away".to_owned(),
);
}
bound.serve(&cx).await.map_err(|error| {
format!("legacy as_proxy gateway HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("legacy as_proxy gateway HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => {
panic!("legacy as_proxy gateway HTTP server failed to start: {error}")
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("legacy as_proxy gateway HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_prefixes_tool_prompt_and_resource() {
let cx = Cx::for_request();
let upstream = spawn_legacy_upstream_http_server();
let gateway = spawn_legacy_as_proxy_http_gateway(upstream.address());
let mut client =
connect_legacy_http_client(&cx, gateway.address(), "e2e-public-http-legacy-as-proxy");
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy tools/list",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("live exact-2024 HTTP as_proxy must list the prefixed upstream tool");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_LEGACY_PROXY_TOOL_NAME),
"legacy as_proxy_typed must rewrite the upstream tool name: {listed:?}"
);
assert!(
!listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME),
"a nonempty as_proxy prefix must not keep the unprefixed upstream tool: {listed:?}"
);
let called = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_LEGACY_PROXY_TOOL_NAME,
json!({ "value": PUBLIC_HTTP_TOOL_ARGUMENT }),
"exact-2024 as_proxy tools/call",
)
.expect("the prefixed upstream tool must dispatch through the live as_proxy gateway");
assert_eq!(
legacy_http_tool_text(&called).as_deref(),
Some(PUBLIC_HTTP_TOOL_TEXT),
"the proxied live tool must retain the upstream handler value: {called:?}"
);
let listed_prompts = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy prompts/list",
client.list_prompts(&cx, legacy_2024::ListPromptsParams::default()),
)
.expect("live exact-2024 HTTP as_proxy must list the prefixed upstream prompt");
assert!(
listed_prompts
.prompts
.iter()
.any(|prompt| prompt.name == PUBLIC_HTTP_LEGACY_PROXY_PROMPT_NAME),
"legacy as_proxy_typed must rewrite the upstream prompt name: {listed_prompts:?}"
);
let prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy prompts/get",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: PUBLIC_HTTP_LEGACY_PROXY_PROMPT_NAME.to_owned(),
arguments: Some(HashMap::from([(
"subject".to_owned(),
PUBLIC_HTTP_TOOL_ARGUMENT.to_owned(),
)])),
meta: None,
},
),
)
.expect("the prefixed upstream prompt must dispatch through the live as_proxy gateway");
let prompt = serde_json::to_value(prompt)
.expect("the proxied prompt result serializes for its observable assertion");
assert_eq!(
prompt["messages"][0]["content"]["text"],
json!(PUBLIC_HTTP_PROMPT_TEXT),
"the proxied live prompt must retain the upstream handler value: {prompt:?}"
);
let listed_resources = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy resources/list",
client.list_resources(&cx, legacy_2024::ListResourcesParams::default()),
)
.expect("live exact-2024 HTTP as_proxy must list the prefixed upstream resource");
assert!(
listed_resources
.resources
.iter()
.any(|resource| resource.uri == PUBLIC_HTTP_LEGACY_PROXY_RESOURCE_URI),
"legacy as_proxy_typed must prefix every resource key: {listed_resources:?}"
);
assert!(
!listed_resources
.resources
.iter()
.any(|resource| resource.uri == PUBLIC_HTTP_RESOURCE_URI),
"legacy as_proxy_typed must not keep the unprefixed upstream resource URI: {listed_resources:?}"
);
let resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy resources/read",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_LEGACY_PROXY_RESOURCE_URI.to_owned(),
meta: None,
},
),
)
.expect("the prefixed upstream resource must dispatch through the live as_proxy gateway");
let resource_text = serde_json::to_value(&resource)
.ok()
.and_then(|value| value["contents"][0]["text"].as_str().map(str::to_owned));
assert_eq!(
resource_text.as_deref(),
Some(PUBLIC_HTTP_RESOURCE_TEXT),
"the proxied live resource must retain the upstream handler value: {resource:?}"
);
let original = legacy_http_call(
&cx,
&mut client,
PUBLIC_HTTP_TOOL_NAME,
json!({ "value": PUBLIC_HTTP_TOOL_ARGUMENT }),
"exact-2024 unprefixed tool after as_proxy",
)
.expect_err("the unprefixed upstream tool name must stay unknown");
let original = format!("{original:?}");
assert!(
original.contains("Unknown tool")
|| original.contains("Method not found")
|| original.contains("MethodNotFound"),
"calling the unprefixed upstream name must stay unknown: {original}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_forwards_resource_template_completion() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_template_completion_upstream();
let gateway = spawn_legacy_as_proxy_http_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-template-complete",
);
let prefixed = format!("child/{PUBLIC_HTTP_CURSOR_TEMPLATE}");
let completed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy template completion/complete",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Resource { uri: prefixed },
argument: legacy_2024::LegacyCompletionArgument {
name: "id".to_owned(),
value: "al".to_owned(),
},
meta: None,
},
),
)
.expect(
"exact-2024 as_proxy must rewrite the prefixed template URI onto the upstream provider",
);
assert_eq!(
completed.completion.values,
vec![PUBLIC_HTTP_TEMPLATE_COMPLETION_LEGACY_VALUE.to_owned()],
"exact-2024 as_proxy must retain the upstream template completion: {completed:?}"
);
let unprefixed = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy unprefixed template completion",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Resource {
uri: PUBLIC_HTTP_CURSOR_TEMPLATE.to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "id".to_owned(),
value: "al".to_owned(),
},
meta: None,
},
),
)
.expect_err("changing only the prefix must not reach the exact-2024 upstream provider");
let _ = unprefixed;
let other = runtime_block_on_bounded_named(
&cx,
"exact-2024 HTTP as_proxy other template completion",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Resource {
uri: format!("child/{PUBLIC_HTTP_OTHER_TEMPLATE}"),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "id".to_owned(),
value: "al".to_owned(),
},
meta: None,
},
),
)
.expect_err("changing only the template URI must not invent an exact-2024 completion provider");
let _ = other;
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_advertises_completions_when_upstream_supports() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_template_completion_upstream();
let gateway = spawn_legacy_as_proxy_http_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-complete-advertise",
);
assert!(
client.server_capabilities().completions.is_some(),
"exact-2024 as_proxy must advertise initialize completions when the upstream installed a provider: {:?}",
client.server_capabilities()
);
let prefixed = format!("child/{PUBLIC_HTTP_CURSOR_TEMPLATE}");
let completed = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy advertised completion/complete",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Resource { uri: prefixed },
argument: legacy_2024::LegacyCompletionArgument {
name: "id".to_owned(),
value: "al".to_owned(),
},
meta: None,
},
),
)
.expect("advertised exact-2024 as_proxy completions must still forward the prefixed template");
assert_eq!(
completed.completion.values,
vec![PUBLIC_HTTP_TEMPLATE_COMPLETION_LEGACY_VALUE.to_owned()],
"exact-2024 as_proxy must retain the upstream template completion after advertising: {completed:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_http_legacy_as_proxy_omits_completions_when_upstream_has_none() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_counting_upstream();
let gateway = spawn_legacy_as_proxy_http_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-complete-omit",
);
assert!(
client.server_capabilities().completions.is_none(),
"changing only the missing upstream completion provider must omit exact-2024 as_proxy completions: {:?}",
client.server_capabilities()
);
let refused = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy omitted completion/complete",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt {
name: format!("child/{PUBLIC_HTTP_PROMPT_NAME}"),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "subject".to_owned(),
value: "al".to_owned(),
},
meta: None,
},
),
)
.expect_err(
"an exact-2024 as_proxy gateway without a completion provider must refuse complete",
);
let _ = refused;
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(feature = "proxy", any(target_os = "linux", target_os = "macos")))]
#[test]
fn e2e_public_http_legacy_as_proxy_forwards_filesystem_template() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_filesystem_upstream();
let gateway = spawn_legacy_as_proxy_http_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-filesystem",
);
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy filesystem resources/templates/list",
client.list_resource_templates(&cx, legacy_2024::ListResourceTemplatesParams::default()),
)
.expect("exact-2024 as_proxy must list the prefixed filesystem template");
let prefixed = format!("child/{PUBLIC_HTTP_FS_TEMPLATE}");
assert!(
listed
.resource_templates
.iter()
.any(|template| template.uri_template == prefixed
&& template.name == PUBLIC_HTTP_FS_PREFIX),
"exact-2024 as_proxy must prefix the filesystem template: {:?}",
listed.resource_templates
);
assert!(
listed
.resource_templates
.iter()
.all(|template| template.uri_template != PUBLIC_HTTP_FS_TEMPLATE),
"a nonempty as_proxy prefix must not keep the unprefixed filesystem template: {:?}",
listed.resource_templates
);
let unmatched = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy unmatched filesystem resources/read",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: format!("child/file:///other/{PUBLIC_HTTP_FS_FILE_NAME}"),
meta: None,
},
),
)
.expect_err("changing only the filesystem prefix must refuse before the upstream handler");
let _ = unmatched;
let unprefixed = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy unprefixed filesystem resources/read",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_FS_FILE_URI.to_owned(),
meta: None,
},
),
)
.expect_err("changing only the as_proxy prefix must not reach the upstream filesystem");
let _ = unprefixed;
let file = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy matched filesystem resources/read",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: format!("child/{PUBLIC_HTTP_FS_FILE_URI}"),
meta: None,
},
),
)
.expect("exact-2024 as_proxy must rewrite the prefixed file URI onto the upstream provider");
let file_text = serde_json::to_value(&file)
.ok()
.and_then(|value| value["contents"][0]["text"].as_str().map(str::to_owned));
assert_eq!(
file_text.as_deref(),
Some(PUBLIC_HTTP_FS_FILE_TEXT),
"exact-2024 as_proxy must retain the upstream filesystem bytes: {file:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
/// Live exact-2024 FilesystemProvider plus the catalog members required by
/// `spawn_legacy_as_proxy_http_gateway`, plus the watch/touch pair used by
/// prefixed `resources/subscribe` proofs.
#[cfg(feature = "proxy")]
fn spawn_legacy_as_proxy_subscription_upstream() -> HttpServerFixture {
spawn_legacy_http_server("legacy as_proxy subscription upstream", || {
ServerBuilder::new("facade-http-legacy-proxy-subscription", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.tool(PublicHttpValue)
.tool(PublicHttpTouchTool)
.tool(PublicHttpLogTool)
.tool(PublicHttpProgressTool)
.tool(PublicHttpSampleTool)
.tool(PublicHttpLegacyRootsTool)
.prompt(PublicHttpInstructionPrompt)
.prompt(PublicHttpProgressPrompt)
.resource(PublicHttpSnapshotResource)
.resource(PublicHttpWatchResource)
.resource(PublicHttpProgressResource)
.build()
})
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_forwards_resource_updated_on_subscribe() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_subscription_upstream();
let gateway = spawn_legacy_as_proxy_http_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-subscribe",
);
let prefixed_resource = format!("child/{PUBLIC_HTTP_WATCH_RESOURCE_URI}");
let prefixed_touch = format!("child/{PUBLIC_HTTP_TOUCH_TOOL_NAME}");
let silent = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy touch without subscribe",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed_touch.clone(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("prefixed touch must complete without a gateway subscribe");
assert_eq!(
legacy_http_tool_text(&silent).as_deref(),
Some("silent"),
"omitting only subscribe must keep upstream notify_resource_updated silent: {silent:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy missing resources/subscribe",
client.subscribe_resource(
&cx,
legacy_2024::SubscribeResourceParams {
uri: format!("child/{PUBLIC_HTTP_WATCH_RESOURCE_URI}-missing"),
},
),
)
.expect_err("changing only the subscribed URI must refuse before the upstream subscribe");
runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy resources/subscribe",
client.subscribe_resource(
&cx,
legacy_2024::SubscribeResourceParams {
uri: prefixed_resource.clone(),
},
),
)
.expect("exact-2024 as_proxy must admit subscribe of the prefixed resource");
let touched = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy touch after subscribe",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed_touch.clone(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("prefixed touch after subscribe must complete");
assert_eq!(
legacy_http_tool_text(&touched).as_deref(),
Some("notified"),
"as_proxy subscribe must count as upstream notify_resource_updated delivery: {touched:?}"
);
let updated =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
updated.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ResourceUpdated(params)
if params.uri == prefixed_resource
)),
"exact-2024 as_proxy must retain prefixed resources/updated after subscribe: {updated:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy resources/unsubscribe",
client.unsubscribe_resource(
&cx,
legacy_2024::UnsubscribeResourceParams {
uri: prefixed_resource.clone(),
},
),
)
.expect("exact-2024 as_proxy must admit unsubscribe of the prefixed resource");
let after_unsub = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy touch after unsubscribe",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed_touch,
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("prefixed touch after unsubscribe must complete");
assert_eq!(
legacy_http_tool_text(&after_unsub).as_deref(),
Some("silent"),
"as_proxy unsubscribe must keep later upstream notify_resource_updated silent: {after_unsub:?}"
);
let later =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
!later.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ResourceUpdated(_)
)),
"exact-2024 as_proxy must not retain resources/updated after unsubscribe: {later:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_forwards_inbound_log_level() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_subscription_upstream();
let gateway = spawn_legacy_as_proxy_http_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-log",
);
let prefixed = format!("child/{PUBLIC_HTTP_LOG_TOOL_NAME}");
runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy log tools/call before setLevel",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed.clone(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("prefixed log tool must complete without setLevel");
let silent = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent.iter().any(|notification| legacy_http_log_message_is(
notification,
PUBLIC_HTTP_HANDLER_LOG_TEXT
)),
"omitting only setLevel must keep as_proxy ctx.info silent: {silent:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy logging/setLevel Info",
client.set_log_level(&cx, legacy_2024::LogLevel::Info),
)
.expect("exact-2024 as_proxy logging/setLevel must be admitted");
runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy log tools/call after setLevel Info",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed.clone(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("prefixed log tool must complete after setLevel Info");
let info =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
info.iter().any(|notification| legacy_http_log_message_is(
notification,
PUBLIC_HTTP_HANDLER_LOG_TEXT
)),
"exact-2024 as_proxy must retain upstream ctx.info after set_log_level(Info): {info:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy logging/setLevel Emergency",
client.set_log_level(&cx, legacy_2024::LogLevel::Emergency),
)
.expect("raising the as_proxy log floor must be admitted");
runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy log tools/call after setLevel Emergency",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed,
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("raising the log floor must not prevent the prefixed log tool");
let emergency = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!emergency
.iter()
.any(|notification| legacy_http_log_message_is(
notification,
PUBLIC_HTTP_HANDLER_LOG_TEXT
)),
"raising only the inbound log floor must suppress as_proxy ctx.info: {emergency:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_forwards_inbound_progress_marker() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_subscription_upstream();
let gateway = spawn_legacy_as_proxy_http_gateway(upstream.address());
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-progress",
);
let prefixed_tool = format!("child/{PUBLIC_HTTP_PROGRESS_TOOL_NAME}");
let prefixed_resource = format!("child/{PUBLIC_HTTP_PROGRESS_RESOURCE_URI}");
let prefixed_prompt = format!("child/{PUBLIC_HTTP_PROGRESS_PROMPT_NAME}");
runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy progress tools/call before token",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed_tool.clone(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("prefixed progress tool must complete without a progressToken");
let silent = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"omitting only the progressToken must keep as_proxy tools/call silent: {silent:?}"
);
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy progress unprefixed tools/call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_PROGRESS_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: legacy_http_progress_meta(legacy_2024::ProgressMarker::from(
"http-legacy-as-proxy-progress-unprefixed",
)),
},
),
);
assert!(
missing.is_err(),
"changing only the tool name must refuse before the unprefixed progress handler: {missing:?}"
);
let marker = legacy_2024::ProgressMarker::from("http-legacy-as-proxy-progress");
runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy progress tools/call with token",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed_tool,
arguments: Some(json!({})),
meta: legacy_http_progress_meta(marker.clone()),
},
),
)
.expect("prefixed progress tool must complete after a progressToken");
let progress =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == marker
&& params.message.as_deref() == Some("halfway")
)),
"exact-2024 as_proxy must retain upstream tools/call notifications/progress: {progress:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy progress resources/read before token",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: prefixed_resource.clone(),
meta: None,
},
),
)
.expect("prefixed progress resource must complete without a progressToken");
let silent_resource = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent_resource.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"omitting only the progressToken must keep as_proxy resources/read silent: {silent_resource:?}"
);
let resource_marker =
legacy_2024::ProgressMarker::from("http-legacy-as-proxy-resource-progress");
runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy progress resources/read with token",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: prefixed_resource,
meta: legacy_http_progress_meta(resource_marker.clone()),
},
),
)
.expect("prefixed progress resource must complete after a progressToken");
let resource_progress =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
resource_progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == resource_marker
&& params.message.as_deref() == Some("resource-halfway")
)),
"exact-2024 as_proxy must retain upstream resources/read notifications/progress: {resource_progress:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy progress prompts/get before token",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: prefixed_prompt.clone(),
arguments: None,
meta: None,
},
),
)
.expect("prefixed progress prompt must complete without a progressToken");
let silent_prompt = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent_prompt.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"omitting only the progressToken must keep as_proxy prompts/get silent: {silent_prompt:?}"
);
let prompt_marker = legacy_2024::ProgressMarker::from("http-legacy-as-proxy-prompt-progress");
runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy progress prompts/get with token",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: prefixed_prompt,
arguments: None,
meta: legacy_http_progress_meta(prompt_marker.clone()),
},
),
)
.expect("prefixed progress prompt must complete after a progressToken");
let prompt_progress =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
prompt_progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == prompt_marker
&& params.message.as_deref() == Some("prompt-halfway")
)),
"exact-2024 as_proxy must retain upstream prompts/get notifications/progress: {prompt_progress:?}"
);
drop(client);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_forwards_inbound_sampling_and_roots() {
let cx = Cx::for_request();
let upstream = spawn_legacy_as_proxy_subscription_upstream();
let gateway = spawn_legacy_as_proxy_http_gateway(upstream.address());
let sample_calls = Arc::new(AtomicUsize::new(0));
let roots_calls = Arc::new(AtomicUsize::new(0));
let mut client = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy sampling HTTP connect",
legacy_2024::http_client_builder(
public_http_target(gateway.address(), "/sse"),
public_http_target(gateway.address(), "/messages"),
)
.expect("the exact-2024 as_proxy sampling HTTP endpoints form one public facade plan")
.client_info("e2e-public-http-legacy-as-proxy-sample", "1.0.0")
.capabilities(ClientCapabilities {
sampling: Some(Default::default()),
elicitation: None,
roots: Some(Default::default()),
})
.reverse_request_handlers(
legacy_2024::LegacyReverseRequestHandlers::new()
.with_sampling_create_message({
let sample_calls = Arc::clone(&sample_calls);
move |_cx, _cancel, _params| {
sample_calls.fetch_add(1, Ordering::Relaxed);
Box::pin(async {
Ok(legacy_2024::LegacyCreateMessageResult::text(
PUBLIC_HTTP_SAMPLED_TEXT,
"e2e-legacy-as-proxy-model",
))
})
}
})
.with_roots_list({
let roots_calls = Arc::clone(&roots_calls);
move |_cx, _cancel, _params| {
roots_calls.fetch_add(1, Ordering::Relaxed);
Box::pin(async {
Ok(legacy_2024::ListRootsResult::new(vec![
legacy_2024::Root::with_name(
PUBLIC_HTTP_LEGACY_ROOT_URI,
"workspace",
),
]))
})
}
}),
)
.connect_http_client_with_cx(&cx),
)
.expect("the inbound sampling and roots callbacks are configured before initialize");
let prefixed_sample = format!("child/{PUBLIC_HTTP_SAMPLE_TOOL_NAME}");
let prefixed_roots = format!("child/{PUBLIC_HTTP_LEGACY_ROOTS_TOOL_NAME}");
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy unprefixed sampling tools/call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: PUBLIC_HTTP_SAMPLE_TOOL_NAME.to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
);
assert!(
missing.is_err(),
"changing only the tool name must refuse before the unprefixed sampling handler: {missing:?}"
);
let sampled = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy sampling tools/call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed_sample,
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("exact-2024 as_proxy must forward sampling/createMessage onto the inbound client");
assert_eq!(
legacy_http_tool_text(&sampled).as_deref(),
Some(PUBLIC_HTTP_SAMPLED_TEXT),
"exact-2024 as_proxy must retain the inbound sampling callback text: {sampled:?}"
);
assert_eq!(
sample_calls.load(Ordering::Relaxed),
1,
"the upstream sample tool must issue exactly one inbound sampling/createMessage"
);
let rooted = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy roots tools/call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed_roots,
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("exact-2024 as_proxy must forward roots/list onto the inbound client");
assert_eq!(
legacy_http_tool_text(&rooted).as_deref(),
Some(PUBLIC_HTTP_LEGACY_ROOT_URI),
"exact-2024 as_proxy must retain the inbound roots callback URI: {rooted:?}"
);
assert_eq!(
roots_calls.load(Ordering::Relaxed),
1,
"the upstream roots tool must issue exactly one inbound roots/list"
);
drop(client);
let mut bare = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-sample-bare",
);
let silent_sample = runtime_block_on_bounded_named(
&cx,
"exact-2024 as_proxy sampling without inbound capability",
bare.call_tool(
&cx,
legacy_2024::CallToolParams {
name: format!("child/{PUBLIC_HTTP_SAMPLE_TOOL_NAME}"),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("missing inbound sampling remains a typed exact-2024 tool result");
let silent_text = legacy_http_tool_text(&silent_sample).unwrap_or_default();
assert!(
silent_sample.is_error
|| silent_text.contains("does not support sampling capability")
|| silent_text.contains("sampling callback is unavailable"),
"omitting only inbound sampling must fail closed: {silent_sample:?}"
);
assert_ne!(
silent_text.as_str(),
PUBLIC_HTTP_SAMPLED_TEXT,
"a bare inbound peer must not invent a sampling callback result: {silent_sample:?}"
);
drop(bare);
gateway.shutdown();
upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_forwards_inbound_sampling_and_roots() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let sample_calls = Arc::new(AtomicUsize::new(0));
let roots_calls = Arc::new(AtomicUsize::new(0));
let mut client = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy sampling HTTP connect",
legacy_2024::http_client_builder(
public_http_target(gateway.address(), "/sse"),
public_http_target(gateway.address(), "/messages"),
)
.expect("the exact-2024 stdio as_proxy sampling HTTP endpoints form one public facade plan")
.client_info("e2e-public-http-legacy-stdio-as-proxy-sample", "1.0.0")
.capabilities(ClientCapabilities {
sampling: Some(Default::default()),
elicitation: None,
roots: Some(Default::default()),
})
.reverse_request_handlers(
legacy_2024::LegacyReverseRequestHandlers::new()
.with_sampling_create_message({
let sample_calls = Arc::clone(&sample_calls);
move |_cx, _cancel, _params| {
sample_calls.fetch_add(1, Ordering::Relaxed);
Box::pin(async {
Ok(legacy_2024::LegacyCreateMessageResult::text(
PUBLIC_HTTP_SAMPLED_TEXT,
"e2e-legacy-stdio-as-proxy-model",
))
})
}
})
.with_roots_list({
let roots_calls = Arc::clone(&roots_calls);
move |_cx, _cancel, _params| {
roots_calls.fetch_add(1, Ordering::Relaxed);
Box::pin(async {
Ok(legacy_2024::ListRootsResult::new(vec![
legacy_2024::Root::with_name(
PUBLIC_HTTP_LEGACY_ROOT_URI,
"workspace",
),
]))
})
}
}),
)
.connect_http_client_with_cx(&cx),
)
.expect("the inbound sampling and roots callbacks are configured before initialize");
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy unprefixed sampling tools/call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "sample_text".to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
);
assert!(
missing.is_err(),
"changing only the tool name must refuse before the unprefixed sampling handler: {missing:?}"
);
let sampled = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy sampling tools/call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/sample_text".to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect(
"exact-2024 stdio as_proxy must forward sampling/createMessage onto the inbound client",
);
assert_eq!(
legacy_http_tool_text(&sampled).as_deref(),
Some(PUBLIC_HTTP_SAMPLED_TEXT),
"exact-2024 stdio as_proxy must retain the inbound sampling callback text: {sampled:?}"
);
assert_eq!(
sample_calls.load(Ordering::Relaxed),
1,
"the upstream sample_text tool must issue exactly one inbound sampling/createMessage"
);
let rooted = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy roots tools/call",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/client_root_uri".to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("exact-2024 stdio as_proxy must forward roots/list onto the inbound client");
assert_eq!(
legacy_http_tool_text(&rooted).as_deref(),
Some(PUBLIC_HTTP_LEGACY_ROOT_URI),
"exact-2024 stdio as_proxy must retain the inbound roots callback URI: {rooted:?}"
);
assert_eq!(
roots_calls.load(Ordering::Relaxed),
1,
"the upstream client_root_uri tool must issue exactly one inbound roots/list"
);
drop(client);
let mut bare = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-sample-bare",
);
let silent_sample = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy sampling without inbound capability",
bare.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/sample_text".to_owned(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("missing inbound sampling remains a typed exact-2024 tool result");
let silent_text = legacy_http_tool_text(&silent_sample).unwrap_or_default();
assert!(
silent_sample.is_error
|| silent_text.contains("does not support sampling capability")
|| silent_text.contains("sampling callback is unavailable"),
"omitting only inbound sampling must fail closed: {silent_sample:?}"
);
assert_ne!(
silent_text.as_str(),
PUBLIC_HTTP_SAMPLED_TEXT,
"a bare inbound peer must not invent a sampling callback result: {silent_sample:?}"
);
drop(bare);
gateway.shutdown();
}
const ECHO_SERVER_INSTRUCTIONS: &str =
"A simple echo server for testing FastMCP. Try calling the 'echo' tool with a message!";
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_retains_upstream_instructions() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-instructions",
);
assert_eq!(
client.instructions(),
Some(ECHO_SERVER_INSTRUCTIONS),
"exact-2024 stdio as_proxy must adopt the echo initialize instructions"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_bare_upstream_omits_instructions() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway_with_env(vec![(
"FASTMCP_NO_INSTRUCTIONS".to_owned(),
"1".to_owned(),
)]);
let client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-instructions-bare",
);
assert_eq!(
client.instructions(),
None,
"changing only FASTMCP_NO_INSTRUCTIONS must keep the as_proxy gateway bare"
);
drop(client);
gateway.shutdown();
}
#[cfg(feature = "proxy")]
fn spawn_legacy_as_proxy_instructions_gateway(upstream: SocketAddr) -> HttpServerFixture {
spawn_legacy_as_proxy_instructions_gateway_named(upstream, None, false, None)
}
#[cfg(feature = "proxy")]
fn spawn_legacy_as_proxy_instructions_gateway_named(
upstream: SocketAddr,
override_instructions: Option<&'static str>,
mask_error_details: bool,
request_timeout_secs: Option<u64>,
) -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("legacy as_proxy instructions gateway reactor initializes"),
)
.build()
.expect("legacy as_proxy instructions gateway installs an owned runtime");
let outcome = runtime.block_on(async move {
let cx =
Cx::current().expect("owned gateway runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err(
"legacy as_proxy instructions gateway control receiver went away".to_owned(),
);
}
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream, "/sse")),
Some(public_http_target(upstream, "/messages")),
"e2e-legacy-http-proxy-instructions-gateway".to_owned(),
"e2e-legacy-http-proxy-instructions-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.map_err(|error| format!("legacy as_proxy instructions HTTP plan failed: {error}"))?;
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-instructions-upstream",
"native-h1:e2e-legacy-instructions-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-http-proxy-instructions".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.map_err(|error| {
format!("live exact-2024 instructions proxy connect failed: {error}")
})?;
let catalog = proxy.catalog_typed().map_err(|error| {
format!("live exact-2024 instructions proxy catalog failed: {error}")
})?;
let mut builder = ServerBuilder::new("e2e-legacy-http-instructions-gateway", "1.0.0")
.protocol_policy(ProtocolPolicy::LegacyOnly)
.expect("LegacyOnly is available")
.mask_error_details(mask_error_details);
if let Some(override_instructions) = override_instructions {
builder = builder.instructions(override_instructions);
}
if let Some(request_timeout_secs) = request_timeout_secs {
builder = builder.request_timeout(request_timeout_secs);
}
let server = builder
.as_proxy_typed("child", proxy, catalog)
.map_err(|error| {
format!("legacy as_proxy_typed instructions install failed: {error}")
})?
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message =
format!("legacy as_proxy instructions gateway bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("legacy as_proxy instructions gateway address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err(
"legacy as_proxy instructions gateway startup receiver went away".to_owned(),
);
}
bound.serve(&cx).await.map_err(|error| {
format!("legacy as_proxy instructions gateway stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("legacy as_proxy instructions gateway startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(message)) => panic!("{message}"),
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("legacy as_proxy instructions gateway startup channel closed")
}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_retains_upstream_instructions() {
let cx = Cx::for_request();
let instructed_upstream = spawn_legacy_upstream_http_server_with_instructions();
let instructed_gateway =
spawn_legacy_as_proxy_instructions_gateway(instructed_upstream.address());
let override_gateway = spawn_legacy_as_proxy_instructions_gateway_named(
instructed_upstream.address(),
Some("gateway-override-instructions"),
false,
None,
);
let instructed = connect_legacy_http_client(
&cx,
instructed_gateway.address(),
"e2e-public-http-legacy-as-proxy-instructions",
);
let overridden = connect_legacy_http_client(
&cx,
override_gateway.address(),
"e2e-public-http-legacy-as-proxy-instructions-override",
);
assert_eq!(
instructed.instructions(),
Some(PUBLIC_HTTP_INSTRUCTIONS),
"exact-2024 HTTP as_proxy must adopt the upstream initialize instructions"
);
assert_eq!(
overridden.instructions(),
Some("gateway-override-instructions"),
"a builder instructions() set before as_proxy_typed must keep the gateway string"
);
drop(instructed);
drop(overridden);
instructed_gateway.shutdown();
override_gateway.shutdown();
instructed_upstream.shutdown();
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_http_legacy_as_proxy_bare_upstream_omits_instructions() {
let cx = Cx::for_request();
let bare_upstream = spawn_legacy_upstream_http_server();
let gateway = spawn_legacy_as_proxy_instructions_gateway(bare_upstream.address());
let client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-as-proxy-instructions-bare",
);
assert_eq!(
client.instructions(),
None,
"changing only the missing upstream instructions must keep the as_proxy gateway bare"
);
drop(client);
gateway.shutdown();
bare_upstream.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_forwards_inbound_log_level() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-log",
);
let prefixed = "ext/echo".to_owned();
runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy echo before setLevel",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed.clone(),
arguments: Some(json!({"message": "log"})),
meta: None,
},
),
)
.expect("prefixed echo must complete without setLevel");
let silent = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent
.iter()
.any(|notification| legacy_http_log_message_is(notification, "echo-handler-info")),
"omitting only setLevel must keep stdio as_proxy ctx.info silent: {silent:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy logging/setLevel Info",
client.set_log_level(&cx, legacy_2024::LogLevel::Info),
)
.expect("exact-2024 stdio as_proxy logging/setLevel must be admitted");
runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy echo after setLevel Info",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed.clone(),
arguments: Some(json!({"message": "log"})),
meta: None,
},
),
)
.expect("prefixed echo must complete after setLevel Info");
let info =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
info.iter()
.any(|notification| legacy_http_log_message_is(notification, "echo-handler-info")),
"exact-2024 stdio as_proxy must retain echo ctx.info after set_log_level(Info): {info:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy logging/setLevel Emergency",
client.set_log_level(&cx, legacy_2024::LogLevel::Emergency),
)
.expect("raising the stdio as_proxy log floor must be admitted");
runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy echo after setLevel Emergency",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed,
arguments: Some(json!({"message": "log"})),
meta: None,
},
),
)
.expect("raising the log floor must not prevent prefixed echo");
let emergency = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!emergency
.iter()
.any(|notification| legacy_http_log_message_is(notification, "echo-handler-info")),
"raising only the inbound log floor must suppress stdio as_proxy ctx.info: {emergency:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_forwards_hide_and_show_of_prefixed_tool() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-hide",
);
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy hide tools/list",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect("the live exact-2024 stdio as_proxy gateway must advertise the prefixed echo catalog");
assert!(
listed.tools.iter().any(|tool| tool.name == "ext/echo")
&& listed.tools.iter().any(|tool| tool.name == "ext/add")
&& listed.tools.iter().any(|tool| tool.name == "ext/hide_echo")
&& listed.tools.iter().any(|tool| tool.name == "ext/show_echo"),
"legacy as_proxy must prefix the live stdio hide/show/echo/add tools: {listed:?}"
);
let before = legacy_http_call(
&cx,
&mut client,
"ext/echo",
json!({ "message": "before-hide" }),
"exact-2024 stdio as_proxy hide echo before",
)
.expect("as_proxy must forward prefixed echo before hide_echo");
assert_eq!(
legacy_http_tool_text(&before).as_deref(),
Some("before-hide"),
"changing only the missing hide must keep prefixed echo admitted: {before:?}"
);
let missing = legacy_http_call(
&cx,
&mut client,
"hide_echo",
json!({}),
"exact-2024 stdio as_proxy hide unprefixed",
)
.expect_err("changing only the tool name must not reach the unprefixed hide_echo handler");
let _ = missing;
let hidden = legacy_http_call(
&cx,
&mut client,
"ext/hide_echo",
json!({}),
"exact-2024 stdio as_proxy hide_echo",
)
.expect("as_proxy must forward hide_echo to the live exact-2024 stdio echo session");
assert_eq!(
legacy_http_tool_text(&hidden).as_deref(),
Some("hidden"),
"as_proxy must retain the stdio disable_tool mutation: {hidden:?}"
);
let still_listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy hide list after",
client.list_tools(&cx, legacy_2024::ListToolsParams::default()),
)
.expect(
"the live exact-2024 stdio as_proxy gateway must keep its install-time catalog after hide",
);
assert!(
still_listed
.tools
.iter()
.any(|tool| tool.name == "ext/echo"),
"as_proxy must not drop prefixed echo from the gateway snapshot: {still_listed:?}"
);
let disabled = legacy_disabled_tool_observable(legacy_http_call(
&cx,
&mut client,
"ext/echo",
json!({ "message": "after-hide" }),
"exact-2024 stdio as_proxy hide echo after",
));
assert!(
disabled.contains("disabled")
|| disabled.contains("echo")
|| disabled.contains("Method not found")
|| disabled.contains("MethodNotFound"),
"hide_echo on the live exact-2024 stdio session must refuse a later ext/echo: {disabled}"
);
let peer = legacy_http_call(
&cx,
&mut client,
"ext/add",
json!({ "a": 2, "b": 3 }),
"exact-2024 stdio as_proxy hide peer",
)
.expect("changing only the tool name must still reach an undisabled stdio handler");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("5"),
"as_proxy must keep undisabled stdio tools callable after hide_echo: {peer:?}"
);
let shown = legacy_http_call(
&cx,
&mut client,
"ext/show_echo",
json!({}),
"exact-2024 stdio as_proxy show_echo",
)
.expect("as_proxy must forward show_echo to the live exact-2024 stdio echo session");
assert_eq!(
legacy_http_tool_text(&shown).as_deref(),
Some("shown"),
"as_proxy must retain the stdio enable_tool mutation: {shown:?}"
);
let restored = legacy_http_call(
&cx,
&mut client,
"ext/echo",
json!({ "message": "after-show" }),
"exact-2024 stdio as_proxy hide echo restored",
)
.expect("show_echo on the live exact-2024 stdio session must restore prefixed echo");
assert_eq!(
legacy_http_tool_text(&restored).as_deref(),
Some("after-show"),
"as_proxy must keep the restored stdio echo tool callable: {restored:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_forwards_hide_catalog_of_prefixed_resource_and_prompt() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-hide-catalog",
);
let before_resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy hide-catalog read before",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "ext/info://server".to_owned(),
meta: None,
},
),
)
.expect("as_proxy must read prefixed ext/info://server before hide_catalog");
let before_resource_text = serde_json::to_value(&before_resource)
.ok()
.and_then(|value| value["contents"][0]["text"].as_str().map(str::to_owned));
assert!(
before_resource_text
.as_deref()
.is_some_and(|text| text.contains("echo-server")),
"changing only the missing hide_catalog must keep prefixed info://server readable: {before_resource:?}"
);
let before_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy hide-catalog prompt before",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: "ext/greeting".to_owned(),
arguments: Some(HashMap::from([("name".to_owned(), "Bea".to_owned())])),
meta: None,
},
),
)
.expect("as_proxy must get prefixed greeting before hide_catalog");
let _ = before_prompt;
let missing = legacy_http_call(
&cx,
&mut client,
"hide_catalog",
json!({}),
"exact-2024 stdio as_proxy hide-catalog unprefixed",
)
.expect_err("changing only the tool name must not reach the unprefixed hide_catalog handler");
let _ = missing;
let hidden = legacy_http_call(
&cx,
&mut client,
"ext/hide_catalog",
json!({}),
"exact-2024 stdio as_proxy hide_catalog",
)
.expect("as_proxy must forward hide_catalog to the live exact-2024 stdio echo session");
assert_eq!(
legacy_http_tool_text(&hidden).as_deref(),
Some("hidden"),
"as_proxy must retain the stdio disable_resource and disable_prompt mutations: {hidden:?}"
);
let still_resources = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy hide-catalog resources/list after",
client.list_resources(&cx, legacy_2024::ListResourcesParams::default()),
)
.expect("the gateway snapshot must keep prefixed ext/info://server after hide_catalog");
assert!(
still_resources
.resources
.iter()
.any(|resource| resource.uri == "ext/info://server"),
"as_proxy must not drop the prefixed resource from the gateway snapshot: {still_resources:?}"
);
let still_prompts = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy hide-catalog prompts/list after",
client.list_prompts(&cx, legacy_2024::ListPromptsParams::default()),
)
.expect("the gateway snapshot must keep prefixed greeting after hide_catalog");
assert!(
still_prompts
.prompts
.iter()
.any(|prompt| prompt.name == "ext/greeting"),
"as_proxy must not drop prefixed greeting from the gateway snapshot: {still_prompts:?}"
);
let disabled_resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy hide-catalog read after",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "ext/info://server".to_owned(),
meta: None,
},
),
);
let disabled_resource = match disabled_resource {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
disabled_resource.contains("disabled")
|| disabled_resource.contains("info://server")
|| disabled_resource.contains("Method not found")
|| disabled_resource.contains("MethodNotFound"),
"hide_catalog on the live exact-2024 stdio session must refuse a later prefixed info://server read: {disabled_resource}"
);
let disabled_prompt = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy hide-catalog prompt after",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: "ext/greeting".to_owned(),
arguments: Some(HashMap::from([("name".to_owned(), "Bea".to_owned())])),
meta: None,
},
),
);
let disabled_prompt = match disabled_prompt {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
disabled_prompt.contains("disabled")
|| disabled_prompt.contains("greeting")
|| disabled_prompt.contains("Method not found")
|| disabled_prompt.contains("MethodNotFound"),
"hide_catalog on the live exact-2024 stdio session must refuse a later ext/greeting get: {disabled_prompt}"
);
let shown = legacy_http_call(
&cx,
&mut client,
"ext/show_catalog",
json!({}),
"exact-2024 stdio as_proxy show_catalog",
)
.expect("as_proxy must forward show_catalog to the live exact-2024 stdio echo session");
assert_eq!(
legacy_http_tool_text(&shown).as_deref(),
Some("shown"),
"as_proxy must retain the stdio enable_resource and enable_prompt mutations: {shown:?}"
);
let restored_resource = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy hide-catalog read restored",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "ext/info://server".to_owned(),
meta: None,
},
),
)
.expect("show_catalog must restore prefixed ext/info://server");
let restored_resource_text = serde_json::to_value(&restored_resource)
.ok()
.and_then(|value| value["contents"][0]["text"].as_str().map(str::to_owned));
assert!(
restored_resource_text
.as_deref()
.is_some_and(|text| text.contains("echo-server")),
"as_proxy must keep the restored stdio resource readable: {restored_resource:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_forwards_inbound_progress_marker() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-progress",
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy echo before token",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/echo".to_owned(),
arguments: Some(json!({"message": "no-token"})),
meta: None,
},
),
)
.expect("prefixed echo must complete without a progressToken");
let silent = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"omitting only the progressToken must keep stdio as_proxy echo silent: {silent:?}"
);
let missing = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy unprefixed echo progress",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "echo".to_owned(),
arguments: Some(json!({"message": "unprefixed"})),
meta: legacy_http_progress_meta(legacy_2024::ProgressMarker::from(
"stdio-legacy-as-proxy-progress-unprefixed",
)),
},
),
);
assert!(
missing.is_err(),
"changing only the tool name must refuse before the unprefixed echo handler: {missing:?}"
);
let marker = legacy_2024::ProgressMarker::from("stdio-legacy-as-proxy-progress");
runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy echo with token",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: "ext/echo".to_owned(),
arguments: Some(json!({"message": "token"})),
meta: legacy_http_progress_meta(marker.clone()),
},
),
)
.expect("prefixed echo must complete after a progressToken");
let progress =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == marker
&& params.message.as_deref() == Some("echoed")
)),
"exact-2024 stdio as_proxy must retain echo notifications/progress: {progress:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy info before token",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "ext/info://server".to_owned(),
meta: None,
},
),
)
.expect("prefixed info://server must complete without a progressToken");
let silent_resource = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent_resource.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"omitting only the progressToken must keep stdio as_proxy info silent: {silent_resource:?}"
);
let resource_marker =
legacy_2024::ProgressMarker::from("stdio-legacy-as-proxy-resource-progress");
runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy info with token",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: "ext/info://server".to_owned(),
meta: legacy_http_progress_meta(resource_marker.clone()),
},
),
)
.expect("prefixed info://server must complete after a progressToken");
let resource_progress =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
resource_progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == resource_marker
&& params.message.as_deref() == Some("info")
)),
"exact-2024 stdio as_proxy must retain info://server notifications/progress: {resource_progress:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy greeting before token",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: "ext/greeting".to_owned(),
arguments: Some(HashMap::from([("name".to_owned(), "no-token".to_owned())])),
meta: None,
},
),
)
.expect("prefixed greeting must complete without a progressToken");
let silent_prompt = take_legacy_http_notifications_until(
&mut client,
Instant::now() + Duration::from_millis(150),
);
assert!(
!silent_prompt.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"omitting only the progressToken must keep stdio as_proxy greeting silent: {silent_prompt:?}"
);
let prompt_marker = legacy_2024::ProgressMarker::from("stdio-legacy-as-proxy-prompt-progress");
runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy greeting with token",
client.get_prompt(
&cx,
legacy_2024::GetPromptParams {
name: "ext/greeting".to_owned(),
arguments: Some(HashMap::from([("name".to_owned(), "token".to_owned())])),
meta: legacy_http_progress_meta(prompt_marker.clone()),
},
),
)
.expect("prefixed greeting must complete after a progressToken");
let prompt_progress =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
prompt_progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == prompt_marker
&& params.message.as_deref() == Some("greeted")
)),
"exact-2024 stdio as_proxy must retain greeting notifications/progress: {prompt_progress:?}"
);
drop(client);
gateway.shutdown();
}
/// Live exact-2024 stdio `as_proxy("ext")` HTTP gateway against shipped echo
/// so prefixed `ext/info://server` subscribe rewrites onto echo
/// `touch_server_info` / `info://server`.
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_forwards_resource_updated_on_subscribe() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-subscribe",
);
let prefixed_resource = "ext/info://server".to_owned();
let prefixed_touch = "ext/touch_server_info".to_owned();
let silent = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy touch without subscribe",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed_touch.clone(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("prefixed echo touch must complete without a gateway subscribe");
assert_eq!(
legacy_http_tool_text(&silent).as_deref(),
Some("silent"),
"omitting only subscribe must keep echo notify_resource_updated silent: {silent:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy missing resources/subscribe",
client.subscribe_resource(
&cx,
legacy_2024::SubscribeResourceParams {
uri: "ext/info://server-missing".to_owned(),
},
),
)
.expect_err("changing only the subscribed URI must refuse before the echo subscribe");
runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy resources/subscribe",
client.subscribe_resource(
&cx,
legacy_2024::SubscribeResourceParams {
uri: prefixed_resource.clone(),
},
),
)
.expect("exact-2024 stdio as_proxy must admit subscribe of the prefixed info resource");
let touched = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy touch after subscribe",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed_touch.clone(),
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("prefixed echo touch after subscribe must complete");
assert_eq!(
legacy_http_tool_text(&touched).as_deref(),
Some("notified"),
"stdio as_proxy subscribe must count as echo notify_resource_updated delivery: {touched:?}"
);
let updated =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
updated.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ResourceUpdated(params)
if params.uri == prefixed_resource
)),
"exact-2024 stdio as_proxy must retain prefixed resources/updated after subscribe: {updated:?}"
);
runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy resources/unsubscribe",
client.unsubscribe_resource(
&cx,
legacy_2024::UnsubscribeResourceParams {
uri: prefixed_resource.clone(),
},
),
)
.expect("exact-2024 stdio as_proxy must admit unsubscribe of the prefixed info resource");
let after_unsub = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy touch after unsubscribe",
client.call_tool(
&cx,
legacy_2024::CallToolParams {
name: prefixed_touch,
arguments: Some(json!({})),
meta: None,
},
),
)
.expect("prefixed echo touch after unsubscribe must complete");
assert_eq!(
legacy_http_tool_text(&after_unsub).as_deref(),
Some("silent"),
"stdio as_proxy unsubscribe must keep later echo notify_resource_updated silent: {after_unsub:?}"
);
let later =
take_legacy_http_notifications_until(&mut client, Instant::now() + Duration::from_secs(1));
assert!(
!later.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ResourceUpdated(_)
)),
"exact-2024 stdio as_proxy must not retain resources/updated after unsubscribe: {later:?}"
);
drop(client);
gateway.shutdown();
}
/// Live exact-2024 stdio `as_proxy("ext")` HTTP gateway against shipped echo
/// with `FASTMCP_FS_ROOT` so prefixed `ext/file:///e2e/{+path}` rewrites onto
/// the echo FilesystemProvider.
#[cfg(all(unix, feature = "proxy", any(target_os = "linux", target_os = "macos")))]
fn spawn_legacy_http_stdio_as_proxy_filesystem_gateway() -> HttpServerFixture {
let root = std::env::temp_dir().join(format!(
"fastmcp-public-http-legacy-stdio-as-proxy-fs-e2e-{}",
std::process::id()
));
std::fs::create_dir_all(&root)
.expect("the exact-2024 stdio as_proxy filesystem e2e root is created");
std::fs::write(
root.join(PUBLIC_HTTP_FS_FILE_NAME),
PUBLIC_HTTP_FS_FILE_TEXT,
)
.expect("the exact-2024 stdio as_proxy filesystem e2e file is written");
let root_path = root
.to_str()
.expect("the exact-2024 stdio as_proxy filesystem e2e root is utf-8")
.to_owned();
spawn_legacy_http_stdio_as_proxy_gateway_with_env(vec![
("FASTMCP_FS_ROOT".to_owned(), root_path),
(
"FASTMCP_FS_PREFIX".to_owned(),
PUBLIC_HTTP_FS_PREFIX.to_owned(),
),
])
}
#[cfg(all(unix, feature = "proxy", any(target_os = "linux", target_os = "macos")))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_forwards_filesystem_template() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_filesystem_gateway();
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-filesystem",
);
let listed = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy filesystem resources/templates/list",
client.list_resource_templates(&cx, legacy_2024::ListResourceTemplatesParams::default()),
)
.expect("exact-2024 stdio as_proxy must list the prefixed filesystem template");
let prefixed = format!("ext/{PUBLIC_HTTP_FS_TEMPLATE}");
assert!(
listed
.resource_templates
.iter()
.any(|template| template.uri_template == prefixed
&& template.name == PUBLIC_HTTP_FS_PREFIX),
"exact-2024 stdio as_proxy must prefix the filesystem template: {:?}",
listed.resource_templates
);
assert!(
listed
.resource_templates
.iter()
.all(|template| template.uri_template != PUBLIC_HTTP_FS_TEMPLATE),
"a nonempty as_proxy prefix must not keep the unprefixed filesystem template: {:?}",
listed.resource_templates
);
let unmatched = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy unmatched filesystem resources/read",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: format!("ext/file:///other/{PUBLIC_HTTP_FS_FILE_NAME}"),
meta: None,
},
),
)
.expect_err("changing only the filesystem prefix must refuse before the upstream handler");
let _ = unmatched;
let unprefixed = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy unprefixed filesystem resources/read",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: PUBLIC_HTTP_FS_FILE_URI.to_owned(),
meta: None,
},
),
)
.expect_err("changing only the as_proxy prefix must not reach the upstream filesystem");
let _ = unprefixed;
let file = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy matched filesystem resources/read",
client.read_resource(
&cx,
legacy_2024::ReadResourceParams {
uri: format!("ext/{PUBLIC_HTTP_FS_FILE_URI}"),
meta: None,
},
),
)
.expect("exact-2024 stdio as_proxy must rewrite the prefixed file URI onto the echo provider");
let file_text = serde_json::to_value(&file)
.ok()
.and_then(|value| value["contents"][0]["text"].as_str().map(str::to_owned));
assert_eq!(
file_text.as_deref(),
Some(PUBLIC_HTTP_FS_FILE_TEXT),
"exact-2024 stdio as_proxy must retain the echo filesystem bytes: {file:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_forwards_resource_template_completion() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-template-complete",
);
let completed = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy template completion/complete",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Resource {
uri: "ext/note://{name}".to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "name".to_owned(),
value: "al".to_owned(),
},
meta: None,
},
),
)
.expect(
"exact-2024 stdio as_proxy must rewrite the prefixed note template onto the echo provider",
);
assert_eq!(
completed.completion.values,
vec!["stdio-note-completion-legacy".to_owned()],
"exact-2024 stdio as_proxy must retain the echo note-template completion: {completed:?}"
);
let unprefixed = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy unprefixed template completion",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Resource {
uri: "note://{name}".to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "name".to_owned(),
value: "al".to_owned(),
},
meta: None,
},
),
)
.expect_err("changing only the prefix must not reach the exact-2024 echo note provider");
let _ = unprefixed;
let other = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy other template completion",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Resource {
uri: "ext/memo://{name}".to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "name".to_owned(),
value: "al".to_owned(),
},
meta: None,
},
),
)
.expect_err("changing only the template URI must not invent an exact-2024 completion provider");
let _ = other;
let greeting = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy greeting complete after template",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt {
name: "ext/greeting".to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "name".to_owned(),
value: "co".to_owned(),
},
meta: None,
},
),
)
.expect("prefixed greeting complete must stay admitted after template complete");
assert_eq!(
greeting.completion.values,
vec!["stdio-completion-legacy".to_owned()],
"exact-2024 stdio as_proxy must still complete ext/greeting: {greeting:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_advertises_completions_when_echo_supports() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway();
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-complete-advertise",
);
assert!(
client.server_capabilities().completions.is_some(),
"exact-2024 stdio as_proxy must advertise initialize completions when echo installed a provider: {:?}",
client.server_capabilities()
);
let completed = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy advertised completion/complete",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Resource {
uri: "ext/note://{name}".to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "name".to_owned(),
value: "al".to_owned(),
},
meta: None,
},
),
)
.expect("advertised exact-2024 stdio as_proxy completions must still forward prefixed note://{name}");
assert_eq!(
completed.completion.values,
vec!["stdio-note-completion-legacy".to_owned()],
"exact-2024 stdio as_proxy must retain the echo note-template completion after advertising: {completed:?}"
);
drop(client);
gateway.shutdown();
}
#[cfg(all(unix, feature = "proxy"))]
#[test]
fn e2e_public_http_legacy_as_proxy_stdio_omits_completions_when_echo_has_none() {
let cx = Cx::for_request();
let gateway = spawn_legacy_http_stdio_as_proxy_gateway_with_env(vec![(
"FASTMCP_NO_COMPLETIONS".to_owned(),
"1".to_owned(),
)]);
let mut client = connect_legacy_http_client(
&cx,
gateway.address(),
"e2e-public-http-legacy-stdio-as-proxy-complete-omit",
);
assert!(
client.server_capabilities().completions.is_none(),
"changing only FASTMCP_NO_COMPLETIONS must omit exact-2024 stdio as_proxy completions: {:?}",
client.server_capabilities()
);
let refused = runtime_block_on_bounded_named(
&cx,
"exact-2024 stdio as_proxy omitted completion/complete",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt {
name: "ext/greeting".to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "name".to_owned(),
value: "co".to_owned(),
},
meta: None,
},
),
)
.expect_err(
"exact-2024 stdio as_proxy without an echo completion provider must refuse complete",
);
let _ = refused;
drop(client);
gateway.shutdown();
}
#[test]
fn e2e_public_http_lifespan_hooks_run_once_and_peer_stays_unhooked() {
let cx = Cx::for_request();
let hooked_startup = Arc::new(AtomicBool::new(false));
let hooked_shutdown = Arc::new(AtomicBool::new(false));
let bare_startup = Arc::new(AtomicBool::new(false));
let bare_shutdown = Arc::new(AtomicBool::new(false));
let hooked = spawn_modern_lifespan_http_server(
Some(Arc::clone(&hooked_startup)),
Some(Arc::clone(&hooked_shutdown)),
);
let bare = spawn_modern_lifespan_http_server(None, None);
let mut hooked_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-lifespan", "1.0.0")
.connect_http_with_cx(public_http_target(hooked.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects after the startup hook");
runtime_block_on_bounded(
&cx,
hooked_client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.expect("tools/call must be admitted after the startup hook");
assert!(
hooked_startup.load(Ordering::SeqCst),
"bind_http serve must run the public startup hook before admitting traffic"
);
assert!(
!hooked_shutdown.load(Ordering::SeqCst),
"the shutdown hook must not run while the listener is still serving"
);
assert!(
!bare_startup.load(Ordering::SeqCst) && !bare_shutdown.load(Ordering::SeqCst),
"a peer without hooks must not invent lifespan events"
);
drop(hooked_client);
hooked.shutdown();
assert!(
hooked_shutdown.load(Ordering::SeqCst),
"cooperative HTTP shutdown must run the public shutdown hook"
);
let mut bare_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-lifespan-bare", "1.0.0")
.connect_http_with_cx(public_http_target(bare.address(), "/mcp"), &cx),
)
.expect("the unhooked peer must still admit a client");
runtime_block_on_bounded(
&cx,
bare_client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.expect("the unhooked peer must still admit tools/call");
drop(bare_client);
bare.shutdown();
assert!(
!bare_startup.load(Ordering::SeqCst) && !bare_shutdown.load(Ordering::SeqCst),
"changing only the missing hooks must leave the peer unhooked after shutdown"
);
}
fn discover_capability_map(client: &modern::HttpClient) -> serde_json::Value {
serde_json::to_value(client.server_discovery().capabilities())
.expect("final discovery capabilities serialize")
}
#[test]
fn e2e_public_http_discovery_advertises_completions_only_when_handler_is_installed() {
let cx = Cx::for_request();
let advertised = spawn_modern_facade_http_server(false, None, false);
let omitted = spawn_modern_instructions_http_server(false);
let mut advertised_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-completions", "1.0.0")
.connect_http_with_cx(public_http_target(advertised.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the completion HTTP server");
let mut omitted_client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-completions-omitted", "1.0.0")
.connect_http_with_cx(public_http_target(omitted.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the completion-omitted HTTP server");
let advertised_caps = discover_capability_map(&advertised_client);
assert!(
advertised_caps.get("completions").is_some(),
"installing a completion handler must advertise completions: {advertised_caps}"
);
let omitted_caps = discover_capability_map(&omitted_client);
assert!(
omitted_caps.get("completions").is_none(),
"changing only the missing completion handler must omit completions: {omitted_caps}"
);
let params = modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: PUBLIC_HTTP_PROMPT_NAME.to_owned(),
title: "Public HTTP E2E Prompt".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
context: Some(modern::FinalCompletionContext {
arguments: Some(std::collections::BTreeMap::from([(
"region".to_owned(),
"us-east-1".to_owned(),
)])),
}),
};
let completed = runtime_block_on_bounded(&cx, advertised_client.complete(&cx, params.clone()))
.expect("live bind_http must complete when completions are advertised");
assert_eq!(
completed.completion.values,
vec![PUBLIC_HTTP_COMPLETION_VALUE.to_owned()],
"the advertised completion provider must retain its values: {completed:?}"
);
let missing_subject = runtime_block_on_bounded(
&cx,
advertised_client.get_prompt(&cx, PUBLIC_HTTP_PROMPT_NAME, HashMap::new()),
)
.expect_err("changing only the missing required prompt argument must refuse");
let missing_subject = format!("{missing_subject:?}");
assert!(
missing_subject.contains("subject")
|| missing_subject.contains("required")
|| missing_subject.contains("InvalidParams")
|| missing_subject.contains("invalid"),
"a missing required prompt argument must stay a handler-visible refusal: {missing_subject}"
);
let prompted = runtime_block_on_bounded(
&cx,
advertised_client.get_prompt(
&cx,
PUBLIC_HTTP_PROMPT_NAME,
HashMap::from([("subject".to_owned(), PUBLIC_HTTP_TOOL_ARGUMENT.to_owned())]),
),
)
.expect("supplying the required prompt argument must still be admitted");
assert!(
prompted
.messages
.iter()
.any(|message| match &message.content {
ContentBlock::Text { text, .. } => text == PUBLIC_HTTP_PROMPT_TEXT,
_ => false,
}),
"prompts/get with the required argument must retain the handler text: {prompted:?}"
);
runtime_block_on_bounded(&cx, omitted_client.complete(&cx, params))
.expect_err("changing only the missing completion handler must refuse complete");
drop(advertised_client);
drop(omitted_client);
advertised.shutdown();
omitted.shutdown();
}
fn spawn_modern_default_and_rich_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("default/rich HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-default-rich", "1.0.0")
.tool(PublicHttpDefault)
.tool(PublicHttpRichTool)
.tool(PublicHttpPlainTool)
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("default/rich facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("default/rich facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("default/rich HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("default/rich facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("default/rich facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("default/rich facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("default/rich facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_tool_default_parameter_is_injected_and_overridable() {
let cx = Cx::for_request();
let server = spawn_modern_default_and_rich_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-default", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the default-parameter HTTP server");
let listed = runtime_block_on_bounded(&cx, client.list_tools(&cx, None))
.expect("live bind_http must list the default-parameter tool");
let default_tool = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_DEFAULT_TOOL_NAME)
.expect("the default-parameter tool must remain on the live catalog");
let required = default_tool
.input_schema
.get("required")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
assert!(
required.iter().any(|value| value.as_str() == Some("name")),
"the generated schema must still require name: {default_tool:?}"
);
assert!(
!required
.iter()
.any(|value| value.as_str() == Some("suffix")),
"the defaulted suffix must not stay required: {default_tool:?}"
);
assert_eq!(
default_tool
.input_schema
.pointer("/properties/suffix/default")
.and_then(serde_json::Value::as_str),
Some(PUBLIC_HTTP_DEFAULT_SUFFIX),
"tools/list must retain the generated default: {default_tool:?}"
);
let injected = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_DEFAULT_TOOL_NAME, json!({"name": "World"})),
)
.expect("omitting the defaulted argument must still be admitted");
assert!(
injected.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "greet:World!",
_ => false,
}),
"the generated default must be injected at call time: {injected:?}"
);
let overridden = runtime_block_on_bounded(
&cx,
client.call_tool(
&cx,
PUBLIC_HTTP_DEFAULT_TOOL_NAME,
json!({"name": "World", "suffix": "?"}),
),
)
.expect("supplying the defaulted argument must override the generated default");
assert!(
overridden.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "greet:World?",
_ => false,
}),
"changing only the suffix must override the generated default: {overridden:?}"
);
let missing_name = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_DEFAULT_TOOL_NAME, json!({"suffix": "!"})),
);
let missing_name = match missing_name {
Ok(result) => {
assert!(
result.is_error,
"a missing required generated argument must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
missing_name.contains("name")
|| missing_name.contains("required")
|| missing_name.contains("schema")
|| missing_name.contains("InvalidParams")
|| missing_name.contains("do not match"),
"a missing required generated argument must stay a handler-visible refusal: {missing_name}"
);
drop(client);
server.shutdown();
}
#[test]
fn e2e_public_http_rich_content_retains_image_and_audio_blocks() {
let cx = Cx::for_request();
let server = spawn_modern_default_and_rich_http_server();
let mut client = runtime_block_on_bounded(
&cx,
modern::ClientBuilder::new()
.client_info("e2e-public-http-rich", "1.0.0")
.connect_http_with_cx(public_http_target(server.address(), "/mcp"), &cx),
)
.expect("the ModernOnly public facade connects to the rich-content HTTP server");
let rich = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_RICH_TOOL_NAME, json!({})),
)
.expect("live bind_http must retain image and audio content blocks");
assert!(
rich.content.iter().any(|content| match content {
ContentBlock::Image {
data, mime_type, ..
} => data == PUBLIC_HTTP_IMAGE_DATA && mime_type == PUBLIC_HTTP_IMAGE_MIME,
_ => false,
}),
"tools/call must retain the authored image block: {rich:?}"
);
assert!(
rich.content.iter().any(|content| match content {
ContentBlock::Audio {
data, mime_type, ..
} => data == PUBLIC_HTTP_AUDIO_DATA && mime_type == PUBLIC_HTTP_AUDIO_MIME,
_ => false,
}),
"tools/call must retain the authored audio block: {rich:?}"
);
let peer = runtime_block_on_bounded(
&cx,
client.call_tool(&cx, PUBLIC_HTTP_PLAIN_TOOL_NAME, json!({})),
)
.expect("the text-only peer tool must still be callable");
assert!(
peer.content
.iter()
.all(|content| matches!(content, ContentBlock::Text { .. })),
"changing only the missing rich content must keep the peer text-only: {peer:?}"
);
assert!(
!peer.content.iter().any(|content| matches!(
content,
ContentBlock::Image { .. } | ContentBlock::Audio { .. }
)),
"the text-only peer must not invent image or audio blocks: {peer:?}"
);
drop(client);
server.shutdown();
}
fn spawn_modern_custom_token_http_server() -> HttpServerFixture {
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let tool_calls = Arc::clone(&handler_calls);
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) = mpsc::sync_channel::<Result<HttpServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let outcome = runtime_block_on(async move {
let cx = Cx::current().expect("facade runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("custom token HTTP server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-http-custom-token", "1.0.0")
.auth_provider(TokenAuthProvider::new(PublicHttpCustomVerifier))
.tool(PublicHttpAuthSubject {
counters: tool_calls,
})
.build();
let bound = match server.bind_http(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("custom token facade HTTP server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("custom token facade HTTP server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("custom token HTTP server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("custom token facade HTTP server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut startup = HttpServerStartupGuard {
server_cx: None,
server_cx_rx: Some(server_cx_rx),
finished: Some(finished_rx),
join,
};
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
startup.capture_server_cx();
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
panic!("custom token facade HTTP server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("custom token facade HTTP server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
startup.resume_thread_panic_if_finished();
panic!("custom token facade HTTP server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
startup.capture_server_cx();
let (server_cx, finished, join) = startup.into_parts();
HttpServerFixture {
address,
server_cx,
finished,
shutdown_completion: None,
join,
nonquiescent: None,
handler_calls,
}
}
#[test]
fn e2e_public_http_custom_token_verifier_refuses_wrong_and_commits_subject() {
const MAX_NATIVE_HTTP_RESPONSE_BYTES: usize = 1 << 20;
fn exchange(address: SocketAddr, authorization: Option<&str>, body: &[u8]) -> Vec<u8> {
let mut stream = std::net::TcpStream::connect_timeout(&address, HTTP_OPERATION_BOUND)
.expect("native HTTP client connects to the custom token listener");
stream
.set_read_timeout(Some(HTTP_OPERATION_BOUND))
.expect("native HTTP client read deadline is configured");
stream
.set_write_timeout(Some(HTTP_OPERATION_BOUND))
.expect("native HTTP client write deadline is configured");
let authorization_header =
authorization.map_or_else(String::new, |value| format!("Authorization: {value}\r\n"));
let request = format!(
"POST /mcp HTTP/1.1\r\nHost: {address}\r\n{authorization_header}Accept: application/json\r\nContent-Type: application/json\r\nMCP-Protocol-Version: {}\r\nMcp-Method: tools/call\r\nMcp-Name: {PUBLIC_HTTP_AUTH_TOOL_NAME}\r\nContent-Length: {}\r\n\r\n",
modern::PROTOCOL_VERSION,
body.len(),
);
stream
.write_all(request.as_bytes())
.and_then(|()| stream.write_all(body))
.expect("native HTTP request commits to the custom token listener");
let mut response = Vec::new();
let mut buffer = [0_u8; 8 * 1024];
loop {
let read = stream
.read(&mut buffer)
.expect("native HTTP response reads within its configured deadline");
if read == 0 {
break;
}
assert!(
response
.len()
.checked_add(read)
.is_some_and(|size| size <= MAX_NATIVE_HTTP_RESPONSE_BYTES),
"native HTTP response exceeds the test's bounded response budget"
);
response.extend_from_slice(&buffer[..read]);
}
response
}
fn response_headers(response: &[u8]) -> &str {
let header_end = response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("native HTTP response contains a complete header terminator");
std::str::from_utf8(&response[..header_end])
.expect("native HTTP response headers are ASCII")
}
fn response_json_body(response: &[u8]) -> serde_json::Value {
let header_end = response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("native HTTP response contains a complete header terminator");
let headers = std::str::from_utf8(&response[..header_end])
.expect("native HTTP response headers are ASCII");
let mut content_length = None;
let mut chunked = false;
for header in headers.lines().skip(1) {
let (name, value) = header
.split_once(':')
.expect("native HTTP response header has a field delimiter");
if name.eq_ignore_ascii_case("content-length") {
content_length = Some(
value
.trim()
.parse::<usize>()
.expect("native HTTP Content-Length is a valid byte count"),
);
}
if name.eq_ignore_ascii_case("transfer-encoding")
&& value
.split(',')
.any(|coding| coding.trim().eq_ignore_ascii_case("chunked"))
{
chunked = true;
}
}
let body = &response[header_end + 4..];
let decoded = if chunked {
let mut cursor = 0;
let mut decoded = Vec::new();
loop {
let size_end = body[cursor..]
.windows(2)
.position(|window| window == b"\r\n")
.map(|offset| cursor + offset)
.expect("chunked response contains a complete chunk-size line");
let size_line = std::str::from_utf8(&body[cursor..size_end])
.expect("chunked response chunk size is ASCII");
let size =
usize::from_str_radix(size_line.split(';').next().unwrap_or_default(), 16)
.expect("chunked response chunk size is hexadecimal");
cursor = size_end + 2;
if size == 0 {
return serde_json::from_slice(&decoded)
.expect("authenticated tool response is JSON-RPC");
}
let chunk_end = cursor
.checked_add(size)
.expect("chunked response chunk length does not overflow");
decoded.extend_from_slice(&body[cursor..chunk_end]);
cursor = chunk_end + 2;
}
} else {
let content_length = content_length
.expect("native HTTP response uses Content-Length or chunked framing");
body[..content_length].to_vec()
};
serde_json::from_slice(&decoded).expect("authenticated tool response is JSON-RPC")
}
let server = spawn_modern_custom_token_http_server();
let tool = JsonRpcRequest::new(
"tools/call",
Some(json!({
"name": PUBLIC_HTTP_AUTH_TOOL_NAME,
"arguments": {},
"_meta": {
"io.modelcontextprotocol/protocolVersion": modern::PROTOCOL_VERSION,
"io.modelcontextprotocol/clientCapabilities": {},
},
})),
2_i64,
);
let tool_body = serde_json::to_vec(&tool).expect("exact-modern tool request serializes");
let missing = exchange(server.address(), None, &tool_body);
assert!(
missing.starts_with(b"HTTP/1.1 401"),
"missing Authorization must be refused before the custom verifier runs: {}",
String::from_utf8_lossy(&missing)
);
assert!(
response_headers(&missing).lines().any(|header| header
.to_ascii_lowercase()
.starts_with("www-authenticate:")
&& header.to_ascii_lowercase().contains("bearer")),
"the missing-token challenge must advertise Bearer: {}",
String::from_utf8_lossy(&missing)
);
assert_eq!(
server.handler_call_snapshot().tool,
0,
"missing Authorization must not invoke the authenticated handler"
);
let wrong = exchange(server.address(), Some("Bearer alpha"), &tool_body);
assert!(
wrong.starts_with(b"HTTP/1.1 401"),
"changing only the custom verifier token must be refused before dispatch: {}",
String::from_utf8_lossy(&wrong)
);
assert_eq!(
server.handler_call_snapshot().tool,
0,
"a rejected custom verifier token must not invoke the authenticated handler"
);
let admitted = exchange(
server.address(),
Some(&format!("Bearer {PUBLIC_HTTP_CUSTOM_AUTH_TOKEN}")),
&tool_body,
);
assert!(
admitted.starts_with(b"HTTP/1.1 200"),
"the matching custom verifier token must admit tools/call: {}",
String::from_utf8_lossy(&admitted)
);
let admitted = response_json_body(&admitted);
assert!(
admitted.get("error").is_none(),
"the matching custom verifier token must reach the handler: {admitted}"
);
let content = admitted["result"]["content"]
.as_array()
.cloned()
.unwrap_or_default();
assert!(
content.iter().any(|block| {
block["text"].as_str() == Some(&format!("subject:{PUBLIC_HTTP_CUSTOM_AUTH_SUBJECT}"))
}),
"admitted native HTTP must commit the custom verifier subject into ctx.auth(): {admitted}"
);
assert_eq!(
server.handler_call_snapshot().tool,
1,
"only the matching custom verifier token may invoke the authenticated handler"
);
server.shutdown();
}
#[cfg(feature = "websocket-experimental")]
mod live_websocket_bind {
use super::*;
const PUBLIC_WS_LOG_TOOL_NAME: &str = "public-ws-e2e-log";
const PUBLIC_WS_HANDLER_LOG_TEXT: &str = "public-ws-handler-info";
const WS_SERVER_TEARDOWN_BOUND: Duration = Duration::from_secs(6);
/// Live modern WebSocket tool that emits `ctx.info` and optional progress.
struct PublicWebSocketLogTool;
impl ToolHandler for PublicWebSocketLogTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_WS_LOG_TOOL_NAME.to_owned(),
description: Some(
"Proves live facade WebSocket handler log and progress".to_owned(),
),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
ctx.info(PUBLIC_WS_HANDLER_LOG_TEXT);
ctx.report_progress(0.5, Some("halfway"));
Ok(vec![Content::text("logged")])
}
}
const PUBLIC_WS_TOUCH_TOOL_NAME: &str = "public-ws-e2e-touch";
const PUBLIC_WS_HIDE_TOOL_NAME: &str = "public-ws-e2e-hide";
struct PublicWebSocketTouchTool;
impl ToolHandler for PublicWebSocketTouchTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_WS_TOUCH_TOOL_NAME.to_owned(),
description: Some("Catalog subject for live WebSocket list_changed".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(
&self,
_ctx: &McpContext,
_arguments: serde_json::Value,
) -> McpResult<Vec<Content>> {
Ok(vec![Content::text("touched")])
}
}
struct PublicWebSocketHideTool;
impl ToolHandler for PublicWebSocketHideTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_WS_HIDE_TOOL_NAME.to_owned(),
description: Some("Disables the live WebSocket catalog subject".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
let delivered = ctx.disable_tool(PUBLIC_WS_TOUCH_TOOL_NAME);
Ok(vec![Content::text(if delivered {
"hidden"
} else {
"silent"
})])
}
}
/// Owns one real public `bind_websocket` listener and proves its teardown.
#[allow(dead_code)]
struct WebSocketServerFixture {
address: SocketAddr,
server_cx: Cx,
finished: mpsc::Receiver<Result<WebSocketServerShutdown, String>>,
shutdown_completion: Option<Result<WebSocketServerShutdown, String>>,
join: Option<JoinHandle<()>>,
nonquiescent: Option<WebSocketNonquiescentShutdown>,
}
#[allow(dead_code)]
impl Drop for WebSocketServerFixture {
fn drop(&mut self) {
if self.join.is_none() && self.nonquiescent.is_none() {
return;
}
if let Err(error) = self.settle() {
eprintln!("public WebSocket server fixture drop failed: {error}");
std::process::abort();
}
}
}
#[allow(dead_code)]
impl WebSocketServerFixture {
fn address(&self) -> SocketAddr {
self.address
}
fn settle(&mut self) -> Result<(), String> {
if let Some(shutdown) = self.nonquiescent.as_mut() {
return match runtime_block_on(shutdown.settle_for(WS_SERVER_TEARDOWN_BOUND)) {
Ok(true) => {
self.nonquiescent = None;
Err(
"facade WebSocket server stopped nonquiescently but settled during fixture cleanup"
.to_owned(),
)
}
Ok(false) => Err(format!(
"facade WebSocket server remains nonquiescent after bounded fixture cleanup ({} retained connections)",
shutdown.remaining_connections()
)),
Err(error) => Err(format!(
"facade WebSocket server child settlement failed: {error}"
)),
};
}
self.server_cx.set_cancel_requested(true);
match await_websocket_server_shutdown(
&self.finished,
&mut self.shutdown_completion,
&mut self.join,
)? {
WebSocketServerShutdown::Quiescent => Ok(()),
WebSocketServerShutdown::Nonquiescent(shutdown) => {
self.nonquiescent = Some(shutdown);
self.settle()
}
}
}
fn shutdown(mut self) {
self.settle()
.unwrap_or_else(|error| panic!("public WebSocket server teardown failed: {error}"));
}
}
#[allow(dead_code)]
fn await_websocket_server_shutdown(
finished: &mpsc::Receiver<Result<WebSocketServerShutdown, String>>,
completion: &mut Option<Result<WebSocketServerShutdown, String>>,
join: &mut Option<JoinHandle<()>>,
) -> Result<WebSocketServerShutdown, String> {
let completion_result = if completion.is_some() {
Ok(())
} else {
finished
.recv_timeout(WS_SERVER_TEARDOWN_BOUND)
.map(|shutdown| *completion = Some(shutdown))
.map_err(|error| {
format!("public WebSocket server teardown exceeded its bound: {error}")
})
};
let join_result = join_finished_thread(join, WS_SERVER_TEARDOWN_BOUND, "WebSocket server");
match (completion_result, join_result) {
(Ok(()), Ok(())) => match completion
.take()
.expect("a completed WebSocket shutdown retains its completion report")
{
Ok(shutdown) => Ok(shutdown),
Err(completion) => Err(format!(
"public WebSocket server teardown failed: {completion}"
)),
},
(Ok(()), Err(join)) => Err(join),
(Err(completion), Ok(())) => Err(completion),
(Err(completion), Err(join)) => Err(format!(
"{completion}; owned thread settlement failed: {join}"
)),
}
}
#[allow(dead_code)]
fn spawn_modern_log_websocket_server() -> WebSocketServerFixture {
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) =
mpsc::sync_channel::<Result<WebSocketServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("log facade WebSocket server reactor initializes"),
)
.blocking_threads(4, 64)
.build()
.expect("log facade WebSocket server installs an owned runtime");
let outcome = runtime.block_on(async move {
let cx = Cx::current()
.expect("owned WebSocket runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("log WebSocket server control receiver went away".to_owned());
}
let server = modern::ServerBuilder::new("facade-ws-log", "1.0.0")
.tool(PublicWebSocketLogTool)
.build();
let bound = match server.bind_websocket(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message = format!("log facade WebSocket server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("log facade WebSocket server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("log WebSocket server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("log facade WebSocket server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut server_cx = None;
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
if server_cx.is_none() {
if let Ok(cx) = server_cx_rx.try_recv() {
server_cx = Some(cx);
}
}
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
if let Some(cx) = server_cx.as_ref() {
cx.set_cancel_requested(true);
}
panic!("log facade WebSocket server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => panic!("log facade WebSocket server failed to start: {error}"),
Err(mpsc::RecvTimeoutError::Disconnected) => {
panic!("log facade WebSocket server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
if server_cx.is_none() {
if let Ok(cx) = server_cx_rx.try_recv() {
server_cx = Some(cx);
}
}
WebSocketServerFixture {
address,
server_cx: server_cx.expect("startup retains the runtime-installed server context"),
finished: finished_rx,
shutdown_completion: None,
join,
nonquiescent: None,
}
}
fn spawn_modern_lifespan_websocket_server(
startup: Option<Arc<AtomicBool>>,
shutdown: Option<Arc<AtomicBool>>,
) -> WebSocketServerFixture {
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) =
mpsc::sync_channel::<Result<WebSocketServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("lifespan facade WebSocket server reactor initializes"),
)
.blocking_threads(4, 64)
.build()
.expect("lifespan facade WebSocket server installs an owned runtime");
let outcome = runtime.block_on(async move {
let cx = Cx::current()
.expect("owned WebSocket runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err("lifespan WebSocket server control receiver went away".to_owned());
}
let builder =
modern::ServerBuilder::new("facade-ws-lifespan", "1.0.0").tool(PublicHttpValue);
let builder = match startup {
Some(flag) => builder.on_startup(move || -> Result<(), std::io::Error> {
flag.store(true, Ordering::SeqCst);
Ok(())
}),
None => builder,
};
let builder = match shutdown {
Some(flag) => builder.on_shutdown(move || {
flag.store(true, Ordering::SeqCst);
}),
None => builder,
};
let server = builder.build();
let bound = match server.bind_websocket(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message =
format!("lifespan facade WebSocket server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message =
format!("lifespan facade WebSocket server address failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err("lifespan WebSocket server startup receiver went away".to_owned());
}
bound.serve(&cx).await.map_err(|error| {
format!("lifespan facade WebSocket server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut server_cx = None;
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
if server_cx.is_none() {
if let Ok(cx) = server_cx_rx.try_recv() {
server_cx = Some(cx);
}
}
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
if let Some(cx) = server_cx.as_ref() {
cx.set_cancel_requested(true);
}
panic!("lifespan facade WebSocket server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => {
panic!("lifespan facade WebSocket server failed to start: {error}")
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
panic!("lifespan facade WebSocket server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
if server_cx.is_none() {
if let Ok(cx) = server_cx_rx.try_recv() {
server_cx = Some(cx);
}
}
WebSocketServerFixture {
address,
server_cx: server_cx.expect("startup retains the runtime-installed server context"),
finished: finished_rx,
shutdown_completion: None,
join,
nonquiescent: None,
}
}
async fn websocket_client_bounded<F: std::future::Future>(
cx: &Cx,
operation: &str,
future: F,
) -> F::Output {
asupersync::time::timeout(cx.now(), HTTP_OPERATION_BOUND, future)
.await
.unwrap_or_else(|_| panic!("{operation} stays within its caller-owned deadline"))
}
fn websocket_test_runtime() -> asupersync::runtime::Runtime {
asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("live bind_websocket reactor initializes"),
)
.blocking_threads(4, 64)
.build()
.expect("live bind_websocket installs an owned runtime")
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
fn connect_warmed_stdio_as_proxy_upstream(cx: &Cx, client_name: &str) -> modern::Client {
let mut last_error = None;
for attempt in 1_u32..=4 {
match modern::ClientBuilder::new()
.client_info(client_name, "1.0.0")
.env("FASTMCP_PROTOCOL_POLICY", "modern-only")
.max_retries(2)
.retry_delay_ms(150)
.connect_stdio_with_cx(env!("CARGO_BIN_EXE_echo_server"), &[], cx)
{
Ok(mut client) => match client.list_tools(None) {
Ok(_) => return client,
Err(error) => {
last_error = Some(error);
drop(client);
}
},
Err(error) => last_error = Some(error),
}
if attempt < 4 {
thread::sleep(Duration::from_millis(50 * u64::from(attempt)));
}
}
panic!(
"live stdio as_proxy upstream connect/list_tools failed: {:?}",
last_error
);
}
const PUBLIC_WS_WATCH_RESOURCE_URI: &str = "test://public-ws-e2e/watched";
const PUBLIC_WS_NOTIFY_TOOL_NAME: &str = "public-ws-e2e-notify";
const PUBLIC_WS_MISS_TOOL_NAME: &str = "public-ws-e2e-miss";
struct PublicWebSocketWatchResource;
impl ResourceHandler for PublicWebSocketWatchResource {
fn definition(&self) -> Resource {
Resource {
uri: PUBLIC_WS_WATCH_RESOURCE_URI.to_owned(),
name: "public-ws-e2e-watched".to_owned(),
description: Some("Proves live facade WebSocket resources/updated".to_owned()),
mime_type: Some("text/plain".to_owned()),
icon: None,
version: None,
tags: Vec::new(),
}
}
fn read(&self, _ctx: &McpContext) -> McpResult<Vec<ResourceContent>> {
Ok(vec![ResourceContent {
uri: PUBLIC_WS_WATCH_RESOURCE_URI.to_owned(),
mime_type: None,
text: Some("watched".to_owned()),
blob: None,
}])
}
}
struct PublicWebSocketNotifyTool;
impl ToolHandler for PublicWebSocketNotifyTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_WS_NOTIFY_TOOL_NAME.to_owned(),
description: Some("Publishes resources/updated for the watched URI".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
let delivered = ctx.notify_resource_updated(PUBLIC_WS_WATCH_RESOURCE_URI);
Ok(vec![Content::text(if delivered {
"notified"
} else {
"silent"
})])
}
}
struct PublicWebSocketMissTool;
impl ToolHandler for PublicWebSocketMissTool {
fn definition(&self) -> Tool {
Tool {
name: PUBLIC_WS_MISS_TOOL_NAME.to_owned(),
description: Some("Publishes resources/updated for an unwatched URI".to_owned()),
input_schema: json!({"type": "object"}),
output_schema: None,
icon: None,
version: None,
tags: Vec::new(),
annotations: None,
}
}
fn call(&self, ctx: &McpContext, _arguments: serde_json::Value) -> McpResult<Vec<Content>> {
let delivered = ctx.notify_resource_updated("test://public-ws-e2e/unwatched");
Ok(vec![Content::text(if delivered {
"notified"
} else {
"silent"
})])
}
}
async fn websocket_upgrade_status(
cx: &Cx,
address: SocketAddr,
authorization: Option<&str>,
) -> (Vec<u8>, asupersync::net::TcpStream) {
use asupersync::io::{AsyncReadExt, AsyncWriteExt};
let mut stream = asupersync::net::TcpStream::connect(address.to_string())
.await
.expect("public bind_websocket upgrade client connects");
let authorization_header = authorization
.map(|value| format!("Authorization: {value}\r\n"))
.unwrap_or_default();
let request = format!(
"GET /mcp HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n{authorization_header}\r\n"
);
websocket_client_bounded(cx, "live bind_websocket upgrade write", async {
stream
.write_all(request.as_bytes())
.await
.expect("public bind_websocket upgrade request writes");
stream
.flush()
.await
.expect("public bind_websocket upgrade request flushes");
})
.await;
let mut received = Vec::new();
let mut byte = [0_u8; 1];
while !received.windows(4).any(|window| window == b"\r\n\r\n") {
let read = websocket_client_bounded(cx, "live bind_websocket upgrade read", async {
stream
.read(&mut byte)
.await
.expect("public bind_websocket upgrade response reads")
})
.await;
if read == 0 {
break;
}
received.push(byte[0]);
}
(received, stream)
}
#[test]
fn e2e_public_websocket_bind_retains_ping_log_and_progress() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("live bind_websocket reactor initializes"),
)
.blocking_threads(4, 64)
.build()
.expect("live bind_websocket installs an owned runtime");
runtime.block_on(async {
let cx = Cx::current()
.expect("owned WebSocket runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-log", "1.0.0")
.tool(PublicWebSocketLogTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public bind_websocket must bind a localhost listener");
let address = bound
.local_addr()
.expect("public bind_websocket publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public bind_websocket serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live bind_websocket handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public bind_websocket must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live bind_websocket initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-bind", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates over bind_websocket");
websocket_client_bounded(&cx, "live bind_websocket ping", client.ping(&cx))
.await
.expect("live bind_websocket must answer modern ping");
client
.set_log_level(modern::LoggingLevel::Info)
.expect("info logLevel is stored as request metadata");
websocket_client_bounded(
&cx,
"live bind_websocket tools/call",
client.call_tool(&cx, PUBLIC_WS_LOG_TOOL_NAME, json!({})),
)
.await
.expect("ctx.info must not prevent the same tools/call from completing");
let info_notifications = client.take_server_notifications();
assert!(
info_notifications.iter().any(|notification| matches!(
notification,
modern::ServerNotification::Message(message)
if message.level == modern::LoggingLevel::Info
&& message.data == json!(PUBLIC_WS_HANDLER_LOG_TEXT)
)),
"live bind_websocket must retain ctx.info after set_log_level(Info): {info_notifications:?}"
);
assert!(
client.take_progress_notifications().is_empty(),
"without a progressToken the handler must not emit request-scoped progress"
);
let marker = modern::ProgressMarker::from("ws-progress");
websocket_client_bounded(
&cx,
"live bind_websocket progress tools/call",
client.call_tool_with_progress_marker(
&cx,
PUBLIC_WS_LOG_TOOL_NAME,
json!({}),
marker.clone(),
),
)
.await
.expect("a progressToken must not prevent the same tools/call from completing");
let progress = client.take_progress_notifications();
assert!(
progress.iter().any(|notification| {
notification.progress_token == marker
&& notification.message.as_deref() == Some("halfway")
}),
"live bind_websocket must retain notifications/progress after a progressToken: {progress:?}"
);
assert!(
client.take_progress_notifications().is_empty(),
"take_progress_notifications must drain the retained queue"
);
let _ = client.take_server_notifications();
client
.set_log_level(modern::LoggingLevel::Emergency)
.expect("emergency logLevel still stores request metadata locally");
websocket_client_bounded(
&cx,
"live bind_websocket emergency tools/call",
client.call_tool(&cx, PUBLIC_WS_LOG_TOOL_NAME, json!({})),
)
.await
.expect("raising only the logLevel floor cannot break the same public tools/call");
let emergency_notifications = client.take_server_notifications();
assert!(
!emergency_notifications.iter().any(|notification| matches!(
notification,
modern::ServerNotification::Message(message)
if message.data == json!(PUBLIC_WS_HANDLER_LOG_TEXT)
)),
"raising only the logLevel floor must suppress ctx.info: {emergency_notifications:?}"
);
websocket_client_bounded(&cx, "live bind_websocket close", client.close(&cx))
.await
.expect("the public WebSocket client closes after the live bind proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_retains_tools_list_changed_on_incremental_listen() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("live bind_websocket listen reactor initializes"),
)
.blocking_threads(4, 64)
.build()
.expect("live bind_websocket listen installs an owned runtime");
runtime.block_on(async {
let cx = Cx::current()
.expect("owned WebSocket listen runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-listen", "1.0.0")
.tool(PublicWebSocketTouchTool)
.tool(PublicWebSocketHideTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public bind_websocket listen must bind a localhost listener");
let address = bound
.local_addr()
.expect("public bind_websocket listen publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public bind_websocket listen serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live bind_websocket listen handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public bind_websocket listen must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live bind_websocket listen initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-listen", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates listen over bind_websocket");
websocket_client_bounded(
&cx,
"live bind_websocket subscriptions/listen",
client.open_subscriptions_listener(
&cx,
modern::SubscriptionFilter {
tools_list_changed: Some(true),
..modern::SubscriptionFilter::default()
},
),
)
.await
.expect("live bind_websocket must admit an incremental subscriptions/listen");
let cancellation = modern::McpRequestCancellation::new();
let acknowledgement = websocket_client_bounded(
&cx,
"live bind_websocket listen acknowledgement",
client.next_subscription_event(&cx, &cancellation),
)
.await
.expect("subscriptions/listen must emit its acknowledgement");
assert!(
matches!(
acknowledgement,
StdioSubscriptionEvent::Acknowledged(ref filter)
if filter.tools_list_changed == Some(true)
),
"the first incremental WebSocket listen record must be the accepted filter: {acknowledgement:?}"
);
let hidden = websocket_client_bounded(
&cx,
"live bind_websocket hide tools/call",
client.call_tool(&cx, PUBLIC_WS_HIDE_TOOL_NAME, json!({})),
)
.await
.expect("disabling a peer tool must complete");
assert!(
hidden.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "hidden",
_ => false,
}),
"request-local SessionState must let disable_tool publish list_changed: {hidden:?}"
);
let changed = websocket_client_bounded(
&cx,
"live bind_websocket list_changed event",
client.next_subscription_event(&cx, &cancellation),
)
.await
.expect("subscriptions/listen must retain tools/list_changed after the handler mutation");
assert!(
matches!(
changed,
StdioSubscriptionEvent::Notification(
modern::ServerNotification::ToolsListChanged(_)
)
),
"live bind_websocket must retain notifications/tools/list_changed on the incremental listener: {changed:?}"
);
websocket_client_bounded(&cx, "live bind_websocket listen tools/list", client.list_tools(&cx, None))
.await
.expect("a later tools/list on the same WebSocket client must still complete");
websocket_client_bounded(&cx, "live bind_websocket listen close", client.close(&cx))
.await
.expect("the public WebSocket listen client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_composes_nested_tool_and_resource() {
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("live bind_websocket compose reactor initializes"),
)
.blocking_threads(4, 64)
.build()
.expect("live bind_websocket compose installs an owned runtime");
runtime.block_on(async {
let cx =
Cx::current().expect("owned WebSocket compose runtime installs an ambient context");
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let server = modern::ServerBuilder::new("facade-ws-compose", "1.0.0")
.tool(CountingPublicHttpValue {
counters: Arc::clone(&handler_calls),
})
.tool(PublicHttpComposeTool)
.resource(CountingPublicHttpSnapshot {
counters: Arc::clone(&handler_calls),
})
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public bind_websocket compose must bind a localhost listener");
let address = bound
.local_addr()
.expect("public bind_websocket compose publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public bind_websocket compose serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live bind_websocket compose handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public bind_websocket compose must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live bind_websocket compose initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-compose", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates compose over bind_websocket");
let composed = websocket_client_bounded(
&cx,
"live bind_websocket compose tools/call",
client.call_tool(
&cx,
PUBLIC_HTTP_COMPOSE_TOOL_NAME,
json!({"value": "alpha"}),
),
)
.await
.expect("live bind_websocket must compose a nested tool call and resource read");
assert!(
composed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text == "compose:tool:alpha|resource:deterministic"
}
_ => false,
}),
"live bind_websocket dispatch must attach request-scoped callers: {composed:?}"
);
assert_eq!(
handler_calls.snapshot().tool,
1,
"the nested tools/call must invoke the peer tool exactly once"
);
assert_eq!(
handler_calls.snapshot().resource,
1,
"the nested resources/read must invoke the peer resource exactly once"
);
let missing = websocket_client_bounded(
&cx,
"live bind_websocket compose missing nested tool",
client.call_tool(
&cx,
PUBLIC_HTTP_COMPOSE_TOOL_NAME,
json!({"value": "alpha", "tool": "public-http-e2e-missing"}),
),
)
.await;
let missing = match missing {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing.contains("public-http-e2e-missing")
|| missing.contains("compose-nested-tool")
|| missing.contains("Unknown tool")
|| missing.contains("not found"),
"the nested unknown tool must stay a handler-visible refusal: {missing}"
);
assert_eq!(
handler_calls.snapshot().tool,
1,
"an unknown nested tool name must not invoke the peer tool"
);
websocket_client_bounded(&cx, "live bind_websocket compose close", client.close(&cx))
.await
.expect("the public WebSocket compose client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_retains_resource_updated_on_incremental_listen() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned WebSocket updated runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-updated", "1.0.0")
.resource(PublicWebSocketWatchResource)
.tool(PublicWebSocketNotifyTool)
.tool(PublicWebSocketMissTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public bind_websocket updated must bind a localhost listener");
let address = bound
.local_addr()
.expect("public bind_websocket updated publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public bind_websocket updated serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live bind_websocket updated handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public bind_websocket updated must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live bind_websocket updated initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-updated", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates updated listen over bind_websocket");
websocket_client_bounded(
&cx,
"live bind_websocket updated subscriptions/listen",
client.open_subscriptions_listener(
&cx,
modern::SubscriptionFilter {
resource_subscriptions: Some(vec![PUBLIC_WS_WATCH_RESOURCE_URI.to_owned()]),
..modern::SubscriptionFilter::default()
},
),
)
.await
.expect("live bind_websocket must admit a resource subscription listen");
let cancellation = modern::McpRequestCancellation::new();
let acknowledgement = websocket_client_bounded(
&cx,
"live bind_websocket updated acknowledgement",
client.next_subscription_event(&cx, &cancellation),
)
.await
.expect("subscriptions/listen must emit its acknowledgement");
assert!(
matches!(
acknowledgement,
StdioSubscriptionEvent::Acknowledged(ref filter)
if filter.resource_subscriptions.as_deref()
== Some(&[PUBLIC_WS_WATCH_RESOURCE_URI.to_owned()][..])
),
"the first incremental WebSocket listen record must be the accepted resource filter: {acknowledgement:?}"
);
let missed = websocket_client_bounded(
&cx,
"live bind_websocket unmatched notify",
client.call_tool(&cx, PUBLIC_WS_MISS_TOOL_NAME, json!({})),
)
.await
.expect("notifying an unwatched URI must complete");
assert!(
missed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "silent",
_ => false,
}),
"an unwatched URI must not count as notify_resource_updated delivery: {missed:?}"
);
let touched = websocket_client_bounded(
&cx,
"live bind_websocket matching notify",
client.call_tool(&cx, PUBLIC_WS_NOTIFY_TOOL_NAME, json!({})),
)
.await
.expect("notifying the watched URI must complete");
assert!(
touched.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "notified",
_ => false,
}),
"a matching incremental listener must count as notify_resource_updated delivery: {touched:?}"
);
let updated = websocket_client_bounded(
&cx,
"live bind_websocket resources/updated event",
client.next_subscription_event(&cx, &cancellation),
)
.await
.expect("subscriptions/listen must retain resources/updated after the matching publish");
assert!(
matches!(
updated,
StdioSubscriptionEvent::Notification(
modern::ServerNotification::ResourceUpdated(ref params)
) if params.uri.as_str() == PUBLIC_WS_WATCH_RESOURCE_URI
),
"live bind_websocket must retain notifications/resources/updated on the incremental listener: {updated:?}"
);
websocket_client_bounded(&cx, "live bind_websocket updated close", client.close(&cx))
.await
.expect("the public WebSocket updated client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_handler_timeout_refuses_late_tool_and_admits_fast_peer() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx =
Cx::current().expect("owned WebSocket timeout runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-timeout", "1.0.0")
.tool(PublicHttpSlowTool)
.tool(PublicHttpFastTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public bind_websocket timeout must bind a localhost listener");
let address = bound
.local_addr()
.expect("public bind_websocket timeout publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public bind_websocket timeout serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live bind_websocket timeout handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public bind_websocket timeout must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live bind_websocket timeout initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-timeout", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates timeout over bind_websocket");
let timed_out = websocket_client_bounded(
&cx,
"live bind_websocket late tools/call",
client.call_tool(&cx, PUBLIC_HTTP_SLOW_TOOL_NAME, json!({})),
)
.await
.expect_err("a handler that outlives its timeout must be refused");
let timed_out = format!("{timed_out:?}");
assert!(
timed_out.contains("Request timeout exceeded")
|| timed_out.contains("RequestCancelled"),
"the refused late tools/call must keep the handler-timeout error: {timed_out}"
);
let fast = websocket_client_bounded(
&cx,
"live bind_websocket fast tools/call",
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.await
.expect("changing only the tool must still be admitted after a handler timeout");
assert!(
fast.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"the fast peer tool must still complete: {fast:?}"
);
websocket_client_bounded(&cx, "live bind_websocket timeout close", client.close(&cx))
.await
.expect("the public WebSocket timeout client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_handler_panic_is_sanitized_and_admits_fast_peer() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx =
Cx::current().expect("owned WebSocket panic runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-panic", "1.0.0")
.tool(PublicHttpPanicTool)
.tool(PublicHttpFastTool)
.resource(PublicHttpPanicResource)
.resource(PublicHttpPlainResource)
.prompt(PublicHttpPanicPrompt)
.prompt(PublicHttpPlainPrompt)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public bind_websocket panic must bind a localhost listener");
let address = bound
.local_addr()
.expect("public bind_websocket panic publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public bind_websocket panic serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live bind_websocket panic handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public bind_websocket panic must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live bind_websocket panic initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-panic", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates panic over bind_websocket");
let panicked = websocket_client_bounded(
&cx,
"live bind_websocket panic tools/call",
client.call_tool(&cx, PUBLIC_HTTP_PANIC_TOOL_NAME, json!({})),
)
.await
.expect_err("a panicking tools/call must stay a protocol error");
let panicked = format!("{panicked:?}");
assert!(
panicked.contains("Internal server error") || panicked.contains("InternalError"),
"a handler panic must become the sanitized InternalError: {panicked}"
);
assert!(
!panicked.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"a handler panic must not leak the unwind payload: {panicked}"
);
let fast = websocket_client_bounded(
&cx,
"live bind_websocket fast tools/call after panic",
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.await
.expect("changing only the tool must still be admitted after a sanitized panic");
assert!(
fast.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"the fast peer tool must still complete after a sanitized panic: {fast:?}"
);
let panicked_resource = websocket_client_bounded(
&cx,
"live bind_websocket panic resources/read",
client.read_resource(&cx, PUBLIC_HTTP_PANIC_RESOURCE_URI),
)
.await
.expect_err("a panicking resources/read must stay a protocol error");
let panicked_resource = format!("{panicked_resource:?}");
assert!(
panicked_resource.contains("Internal server error")
|| panicked_resource.contains("InternalError"),
"a resource panic must become the sanitized InternalError: {panicked_resource}"
);
assert!(
!panicked_resource.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"a resource panic must not leak the unwind payload: {panicked_resource}"
);
let peer_resource = websocket_client_bounded(
&cx,
"live bind_websocket peer resources/read after panic",
client.read_resource(&cx, PUBLIC_HTTP_PLAIN_RESOURCE_URI),
)
.await
.expect("changing only the resource must still be admitted after a sanitized panic");
let peer_resource = format!("{peer_resource:?}");
assert!(
peer_resource.contains("plain-resource"),
"the peer resource must still complete after a sanitized catalog panic: {peer_resource}"
);
let panicked_prompt = websocket_client_bounded(
&cx,
"live bind_websocket panic prompts/get",
client.get_prompt(&cx, PUBLIC_HTTP_PANIC_PROMPT_NAME, HashMap::new()),
)
.await
.expect_err("a panicking prompts/get must stay a protocol error");
let panicked_prompt = format!("{panicked_prompt:?}");
assert!(
panicked_prompt.contains("Internal server error")
|| panicked_prompt.contains("InternalError"),
"a prompt panic must become the sanitized InternalError: {panicked_prompt}"
);
assert!(
!panicked_prompt.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"a prompt panic must not leak the unwind payload: {panicked_prompt}"
);
let peer_prompt = websocket_client_bounded(
&cx,
"live bind_websocket peer prompts/get after panic",
client.get_prompt(&cx, PUBLIC_HTTP_PLAIN_PROMPT_NAME, HashMap::new()),
)
.await
.expect("changing only the prompt must still be admitted after a sanitized panic");
let peer_prompt = format!("{peer_prompt:?}");
assert!(
peer_prompt.contains("plain") || !peer_prompt.is_empty(),
"the peer prompt must still complete after a sanitized catalog panic: {peer_prompt}"
);
websocket_client_bounded(&cx, "live bind_websocket panic close", client.close(&cx))
.await
.expect("the public WebSocket panic client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_completion_panic_is_sanitized_and_admits_fast_peer() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned WebSocket completion-panic runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-completion-panic", "1.0.0")
.tool(PublicHttpFastTool)
.prompt(PublicHttpCompletePeerPrompt)
.prompt_completion_handler(
PUBLIC_HTTP_COMPLETE_PEER_PROMPT_NAME,
PublicHttpPanicCompletion,
)
.completion_handler(PublicHttpPanicCompletion)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public bind_websocket completion panic must bind a localhost listener");
let address = bound
.local_addr()
.expect("public bind_websocket completion panic publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public bind_websocket completion panic serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live bind_websocket completion-panic handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public bind_websocket completion panic must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live bind_websocket completion-panic initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-completion-panic", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates completion panic over bind_websocket");
let panicked = websocket_client_bounded(
&cx,
"live bind_websocket panic completion/complete",
client.complete(&cx, public_http_panic_completion_params()),
)
.await
.expect_err("a panicking completion/complete must stay a protocol error");
let panicked = format!("{panicked:?}");
assert!(
panicked.contains("Internal server error") || panicked.contains("InternalError"),
"a completion panic must become the sanitized InternalError: {panicked}"
);
assert!(
!panicked.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"a completion panic must not leak the unwind payload: {panicked}"
);
let peer_prompt = websocket_client_bounded(
&cx,
"live bind_websocket peer prompts/get after completion panic",
client.get_prompt(&cx, PUBLIC_HTTP_COMPLETE_PEER_PROMPT_NAME, HashMap::new()),
)
.await
.expect("changing only the method must still admit prompts/get after a sanitized completion panic");
let peer_prompt = format!("{peer_prompt:?}");
assert!(
peer_prompt.contains("complete-peer"),
"the peer prompt must still complete after a sanitized completion panic: {peer_prompt}"
);
let fast = websocket_client_bounded(
&cx,
"live bind_websocket fast tools/call after completion panic",
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.await
.expect("changing only the method must still admit tools/call after a sanitized completion panic");
assert!(
fast.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"the fast peer tool must still complete after a sanitized completion panic: {fast:?}"
);
websocket_client_bounded(
&cx,
"live bind_websocket completion-panic close",
client.close(&cx),
)
.await
.expect("the public WebSocket completion-panic client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_static_token_refuses_missing_and_wrong_and_commits_subject() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned WebSocket auth runtime installs an ambient context");
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let verifier = StaticTokenVerifier::new([(
"alpha",
AuthContext::with_subject(PUBLIC_HTTP_AUTH_SUBJECT),
)])
.expect("the deterministic WebSocket bearer verifier is valid")
.with_allowed_schemes(["Bearer"])
.expect("the bearer scheme allowlist is valid");
let server = modern::ServerBuilder::new("facade-ws-auth", "1.0.0")
.auth_provider(TokenAuthProvider::new(verifier))
.tool(PublicHttpAuthSubject {
counters: Arc::clone(&handler_calls),
})
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public bind_websocket auth must bind a localhost listener");
let address = bound
.local_addr()
.expect("public bind_websocket auth publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public bind_websocket auth serve must be admitted");
let (missing, _missing_stream) =
websocket_upgrade_status(&cx, address, None).await;
assert!(
missing.starts_with(b"HTTP/1.1 401"),
"omitting Authorization must refuse the WebSocket upgrade: {}",
String::from_utf8_lossy(&missing)
);
assert!(
String::from_utf8_lossy(&missing).to_ascii_lowercase().contains("www-authenticate"),
"the missing-token WebSocket upgrade must challenge Bearer: {}",
String::from_utf8_lossy(&missing)
);
let (wrong, _wrong_stream) =
websocket_upgrade_status(&cx, address, Some("Bearer wrong")).await;
assert!(
wrong.starts_with(b"HTTP/1.1 401"),
"a wrong bearer token must refuse the WebSocket upgrade: {}",
String::from_utf8_lossy(&wrong)
);
let (accepted, stream) =
websocket_upgrade_status(&cx, address, Some("Bearer alpha")).await;
assert!(
accepted.starts_with(b"HTTP/1.1 101"),
"the matching bearer token must complete RFC 6455 upgrade: {}",
String::from_utf8_lossy(&accepted)
);
let transport = AsyncWsClientTransport::from_upgraded(stream);
let mut client = websocket_client_bounded(
&cx,
"live bind_websocket auth initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-auth", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("an admitted WebSocket upgrade must negotiate ModernOnly");
let admitted = websocket_client_bounded(
&cx,
"live bind_websocket auth tools/call",
client.call_tool(&cx, PUBLIC_HTTP_AUTH_TOOL_NAME, json!({})),
)
.await
.expect("the matching bearer token must reach the authenticated handler");
assert!(
admitted.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text == &format!("subject:{PUBLIC_HTTP_AUTH_SUBJECT}")
}
_ => false,
}),
"admitted WebSocket must commit the static token subject into ctx.auth(): {admitted:?}"
);
assert_eq!(
handler_calls.snapshot().tool,
1,
"only the matching bearer token may invoke the authenticated handler"
);
websocket_client_bounded(&cx, "live bind_websocket auth close", client.close(&cx))
.await
.expect("the public WebSocket auth client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_result_verbs_return_live_input_required() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx =
Cx::current().expect("owned WebSocket MRTR runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-mrtr", "1.0.0")
.tool(PublicHttpSamplingTool)
.tool(PublicHttpRootsTool)
.tool(PublicHttpUrlElicitationTool)
.resource(PublicHttpElicitationResource)
.prompt(PublicHttpElicitationPrompt)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public bind_websocket MRTR must bind a localhost listener");
let address = bound
.local_addr()
.expect("public bind_websocket MRTR publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public bind_websocket MRTR serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live bind_websocket MRTR handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public bind_websocket MRTR must complete RFC 6455 upgrade");
let mut capabilities = ClientCapabilities::default();
capabilities.sampling = Some(Default::default());
capabilities.roots =
serde_json::from_value(json!({})).expect("roots capability is valid");
capabilities.elicitation = serde_json::from_value(json!({"form": {}, "url": {}}))
.expect("form and url elicitation capabilities are valid");
let mut client = websocket_client_bounded(
&cx,
"live bind_websocket MRTR initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-mrtr", "1.0.0")
.capabilities(capabilities)
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates MRTR over bind_websocket");
let sampled = websocket_client_bounded(
&cx,
"live bind_websocket sampling tools/call",
client.call_tool_result(&cx, PUBLIC_HTTP_SAMPLING_TOOL_NAME, json!({})),
)
.await
.expect("live bind_websocket final sampling must return a typed tools/call result");
let FinalCoreResult::ToolsCallInputRequired { result, .. } = sampled else {
panic!("live bind_websocket final sampling must keep input_required: {sampled:?}");
};
assert_live_input_required(&result, "sample", "tools/call");
let rooted = websocket_client_bounded(
&cx,
"live bind_websocket roots tools/call",
client.call_tool_result(&cx, PUBLIC_HTTP_ROOTS_TOOL_NAME, json!({})),
)
.await
.expect("live bind_websocket final roots must return a typed tools/call result");
let FinalCoreResult::ToolsCallInputRequired { result, .. } = rooted else {
panic!("live bind_websocket final roots must keep input_required: {rooted:?}");
};
assert_live_input_required(&result, "roots", "tools/call");
let url_elicitation = websocket_client_bounded(
&cx,
"live bind_websocket URL elicitation tools/call",
client.call_tool_result(&cx, PUBLIC_HTTP_URL_ELICITATION_TOOL_NAME, json!({})),
)
.await
.expect("live bind_websocket final URL elicitation must return a typed tools/call result");
let FinalCoreResult::ToolsCallInputRequired { result, .. } = url_elicitation else {
panic!(
"live bind_websocket final URL elicitation must keep input_required: {url_elicitation:?}"
);
};
assert_live_input_required(&result, "approval", "tools/call");
let resource = websocket_client_bounded(
&cx,
"live bind_websocket form elicitation resources/read",
client.read_resource_result(&cx, PUBLIC_HTTP_ELICITATION_RESOURCE_URI),
)
.await
.expect("live bind_websocket final resource elicitation must return a typed result");
let FinalCoreResult::ResourcesReadInputRequired { result, .. } = resource else {
panic!(
"live bind_websocket resource elicitation must keep input_required: {resource:?}"
);
};
assert_live_input_required(&result, "approval", "resources/read");
let prompt = websocket_client_bounded(
&cx,
"live bind_websocket form elicitation prompts/get",
client.get_prompt_result(&cx, PUBLIC_HTTP_ELICITATION_PROMPT_NAME, HashMap::new()),
)
.await
.expect("live bind_websocket final prompt elicitation must return a typed result");
let FinalCoreResult::PromptsGetInputRequired { result, .. } = prompt else {
panic!(
"live bind_websocket prompt elicitation must keep input_required: {prompt:?}"
);
};
assert_live_input_required(&result, "approval", "prompts/get");
websocket_client_bounded(&cx, "live bind_websocket MRTR close", client.close(&cx))
.await
.expect("the advertised MRTR WebSocket client closes after the live proof");
drop(client);
let missing_transport = websocket_client_bounded(
&cx,
"live bind_websocket MRTR missing-roots handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("a second WebSocket client must complete RFC 6455 upgrade");
let mut no_roots = ClientCapabilities::default();
no_roots.sampling = Some(Default::default());
let mut no_roots_client = websocket_client_bounded(
&cx,
"live bind_websocket MRTR missing-roots initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-mrtr-no-roots", "1.0.0")
.capabilities(no_roots)
.connect_websocket_with_cx(&cx, missing_transport),
)
.await
.expect("the ModernOnly public facade connects without advertising roots");
let missing_roots = websocket_client_bounded(
&cx,
"live bind_websocket missing roots tools/call",
no_roots_client.call_tool_result(&cx, PUBLIC_HTTP_ROOTS_TOOL_NAME, json!({})),
)
.await;
let missing_roots = match missing_roots {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing_roots.contains("not advertised by the client")
|| missing_roots.contains("InvalidRequest")
|| missing_roots.contains("roots"),
"omitting only the roots capability must fail closed: {missing_roots}"
);
websocket_client_bounded(
&cx,
"live bind_websocket MRTR missing-roots close",
no_roots_client.close(&cx),
)
.await
.expect("the no-roots WebSocket client closes after the fail-closed proof");
drop(no_roots_client);
let missing_url_transport = websocket_client_bounded(
&cx,
"live bind_websocket MRTR missing-url handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("a third WebSocket client must complete RFC 6455 upgrade");
let mut no_url = ClientCapabilities::default();
no_url.sampling = Some(Default::default());
no_url.roots = serde_json::from_value(json!({})).expect("roots capability is valid");
no_url.elicitation = serde_json::from_value(json!({"form": {}}))
.expect("form elicitation capability is valid");
let mut no_url_client = websocket_client_bounded(
&cx,
"live bind_websocket MRTR missing-url initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-mrtr-no-url", "1.0.0")
.capabilities(no_url)
.connect_websocket_with_cx(&cx, missing_url_transport),
)
.await
.expect("the ModernOnly public facade connects without advertising URL elicitation");
let missing_url = websocket_client_bounded(
&cx,
"live bind_websocket missing URL elicitation tools/call",
no_url_client.call_tool_result(&cx, PUBLIC_HTTP_URL_ELICITATION_TOOL_NAME, json!({})),
)
.await;
let missing_url = match missing_url {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing_url.contains("not advertised by the client")
|| missing_url.contains("InvalidRequest")
|| missing_url.contains("elicitation"),
"omitting only URL elicitation must fail closed: {missing_url}"
);
websocket_client_bounded(
&cx,
"live bind_websocket MRTR missing-url close",
no_url_client.close(&cx),
)
.await
.expect("the no-url WebSocket client closes after the fail-closed proof");
drop(no_url_client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_follows_installed_elicitation_handlers() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned WebSocket elicitation follow runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-elicit-follow", "1.0.0")
.tool(PublicHttpUrlElicitationTool)
.resource(PublicHttpElicitationResource)
.prompt(PublicHttpElicitationPrompt)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public bind_websocket elicitation follow must bind a localhost listener");
let address = bound
.local_addr()
.expect("public bind_websocket elicitation follow publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public bind_websocket elicitation follow serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live bind_websocket elicitation follow handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public bind_websocket elicitation follow must complete RFC 6455 upgrade");
let mut capabilities = ClientCapabilities::default();
capabilities.elicitation = serde_json::from_value(json!({"form": {}, "url": {}}))
.expect("form and url elicitation capabilities are valid");
let mut client = websocket_client_bounded(
&cx,
"live bind_websocket elicitation follow initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-elicit-follow", "1.0.0")
.capabilities(capabilities)
.modern_reverse_request_handlers(public_modern_elicitation_follow_handlers())
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade installs an elicitation handler before discovery");
let url = websocket_client_bounded(
&cx,
"live bind_websocket URL elicitation follow",
client.call_tool(&cx, PUBLIC_HTTP_URL_ELICITATION_TOOL_NAME, json!({})),
)
.await
.expect("public WebSocket call_tool follows the installed URL elicitation handler");
assert!(
url.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "url-elicit:accept",
_ => false,
}),
"the URL elicitation retry must complete from the installed handler: {url:?}"
);
let resource = websocket_client_bounded(
&cx,
"live bind_websocket form elicitation resource follow",
client.read_resource(&cx, PUBLIC_HTTP_ELICITATION_RESOURCE_URI),
)
.await
.expect("public WebSocket read_resource follows the installed form elicitation handler");
assert!(
resource.contents.iter().any(|content| match content {
EmbeddedResourceContents::Text { text, .. } => text == "form-elicit:true",
_ => false,
}),
"the form elicitation resource retry must complete from the installed handler: {resource:?}"
);
let prompt = websocket_client_bounded(
&cx,
"live bind_websocket form elicitation prompt follow",
client.get_prompt(&cx, PUBLIC_HTTP_ELICITATION_PROMPT_NAME, HashMap::new()),
)
.await
.expect("public WebSocket get_prompt follows the installed form elicitation handler");
assert!(
prompt.messages.iter().any(|message| match &message.content {
ContentBlock::Text { text, .. } => text == "form-elicit:true",
_ => false,
}),
"the form elicitation prompt retry must complete from the installed handler: {prompt:?}"
);
websocket_client_bounded(
&cx,
"live bind_websocket elicitation follow close",
client.close(&cx),
)
.await
.expect("the elicitation-follow WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[cfg(all(feature = "tasks", feature = "proxy"))]
#[test]
fn e2e_public_websocket_bind_official_tasks_create_get_update_and_cancel() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket Tasks runtime installs an ambient context");
let task_runtime = FinalTaskRuntime::in_memory(
FinalTaskRuntimeConfig::new(60_000, Some(5_000))
.expect("modern WebSocket Tasks timing policy is valid"),
Arc::new(|_| {}),
);
let task_runner = task_runtime
.install_task_service(1, Arc::new(PublicHttpHoldingTaskSupervisor))
.expect("modern WebSocket Tasks service install must succeed");
let server = modern::ServerBuilder::new("facade-ws-tasks", "1.0.0")
.tool(PublicHttpTaskTool)
.final_tasks(task_runtime)
.expect("modern WebSocket final_tasks install must succeed")
.build();
let service_scope = cx.scope();
let service = cx
.spawn_in(&service_scope, move |service_cx| async move {
task_runner.run(&service_cx).await
})
.expect("modern WebSocket Tasks service must be admitted");
asupersync::runtime::yield_now().await;
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket Tasks must bind a localhost listener");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket Tasks publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket Tasks serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket Tasks handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket Tasks must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket Tasks initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-tasks", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates official Tasks over bind_websocket");
let created = websocket_client_bounded(
&cx,
"live modern WebSocket Tasks tools/call",
client.call_tool_outcome(&cx, PUBLIC_HTTP_TASK_TOOL_NAME, json!({})),
)
.await
.expect("live bind_websocket must create one official Task");
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the task-capable live tool must return the official Task result branch: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
let input_deadline = Instant::now() + HTTP_OPERATION_BOUND;
let observed = loop {
let observed = websocket_client_bounded(
&cx,
"live modern WebSocket Tasks tasks/get",
client.get_task(&cx, task_id.clone()),
)
.await
.expect("live bind_websocket must serve tasks/get of the created Task");
if matches!(observed.task, FinalTask::InputRequired { .. }) {
break observed;
}
if Instant::now() >= input_deadline {
panic!(
"live bind_websocket must observe the created input_required Task: {observed:?}"
);
}
asupersync::time::sleep(cx.now(), Duration::from_millis(10)).await;
};
assert_eq!(
observed.task.base().task_id,
task_id,
"tasks/get must return the created Task: {observed:?}"
);
let missing = FinalTaskId::parse("missing-ws-direct-task")
.expect("the planted-missing task id is a valid official TaskId");
websocket_client_bounded(
&cx,
"live modern WebSocket missing tasks/get",
client.get_task(&cx, missing.clone()),
)
.await
.expect_err("changing only the task id must not invent a Task");
websocket_client_bounded(
&cx,
"live modern WebSocket missing tasks/cancel",
client.cancel_task(&cx, missing),
)
.await
.expect_err("changing only the task id must not invent a cancellation");
let wrong_update: FinalTaskInputResponses =
serde_json::from_value(json!({"roots": {"action": "accept"}}))
.expect("the planted-wrong Tasks update payload is typed");
websocket_client_bounded(
&cx,
"live modern WebSocket wrong-kind tasks/update",
client.update_task(&cx, &observed.task, wrong_update),
)
.await
.expect_err("changing only the input response kind must not resume the created Task");
let still_waiting = websocket_client_bounded(
&cx,
"live modern WebSocket tasks/get after rejected update",
client.get_task(&cx, task_id.clone()),
)
.await
.expect("a rejected tasks/update must leave the created Task in place");
assert!(
matches!(still_waiting.task, FinalTask::InputRequired { .. }),
"a rejected tasks/update must not leave the input_required Task: {still_waiting:?}"
);
let responses: FinalTaskInputResponses =
serde_json::from_value(json!({"roots": {"roots": []}}))
.expect("the public final roots response is typed");
websocket_client_bounded(
&cx,
"live modern WebSocket tasks/update",
client.update_task(&cx, &observed.task, responses),
)
.await
.expect("live bind_websocket must serve tasks/update of the created Task");
let working_deadline = Instant::now() + HTTP_OPERATION_BOUND;
loop {
let observed = websocket_client_bounded(
&cx,
"live modern WebSocket tasks/get after update",
client.get_task(&cx, task_id.clone()),
)
.await
.expect("live bind_websocket must keep serving tasks/get after update");
if matches!(observed.task, FinalTask::Working(_)) {
break;
}
if Instant::now() >= working_deadline {
panic!(
"live bind_websocket must observe the working Task after a matching update: {observed:?}"
);
}
asupersync::time::sleep(cx.now(), Duration::from_millis(10)).await;
}
websocket_client_bounded(
&cx,
"live modern WebSocket tasks/cancel",
client.cancel_task(&cx, task_id.clone()),
)
.await
.expect("live bind_websocket must serve tasks/cancel of the created Task");
let cancel_deadline = Instant::now() + HTTP_OPERATION_BOUND;
loop {
let observed = websocket_client_bounded(
&cx,
"live modern WebSocket tasks/get after cancel",
client.get_task(&cx, task_id.clone()),
)
.await
.expect("live bind_websocket must keep serving tasks/get after cancel");
if matches!(observed.task, FinalTask::Cancelled(_)) {
break;
}
if Instant::now() >= cancel_deadline {
panic!("live bind_websocket must observe the cancelled Task: {observed:?}");
}
asupersync::time::sleep(cx.now(), Duration::from_millis(10)).await;
}
websocket_client_bounded(&cx, "live modern WebSocket Tasks close", client.close(&cx))
.await
.expect("the modern WebSocket Tasks client closes after the live proof");
drop(client);
service.abort();
cx.set_cancel_requested(true);
listener.abort();
});
}
#[cfg(all(feature = "tasks", feature = "proxy"))]
#[test]
fn e2e_public_websocket_modern_tasks_listen_retains_status_and_catalog_listen_refuses_task_ids()
{
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket Tasks listen runtime installs an ambient context");
let task_runtime = FinalTaskRuntime::in_memory(
FinalTaskRuntimeConfig::new(60_000, Some(5_000))
.expect("modern WebSocket Tasks listen timing policy is valid"),
Arc::new(|_| {}),
);
let task_runner = task_runtime
.install_task_service(1, Arc::new(PublicHttpHoldingTaskSupervisor))
.expect("modern WebSocket Tasks listen service install must succeed");
let server = modern::ServerBuilder::new("facade-ws-tasks-listen", "1.0.0")
.tool(PublicHttpTaskTool)
.final_tasks(task_runtime)
.expect("modern WebSocket final_tasks listen install must succeed")
.build();
let service_scope = cx.scope();
let service = cx
.spawn_in(&service_scope, move |service_cx| async move {
task_runner.run(&service_cx).await
})
.expect("modern WebSocket Tasks listen service must be admitted");
asupersync::runtime::yield_now().await;
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket Tasks listen must bind a localhost listener");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket Tasks listen publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket Tasks listen serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket Tasks listen handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket Tasks listen must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket Tasks listen initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-tasks-listen", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates official Tasks listen over bind_websocket");
let created = websocket_client_bounded(
&cx,
"live modern WebSocket Tasks listen tools/call",
client.call_tool_outcome(&cx, PUBLIC_HTTP_TASK_TOOL_NAME, json!({})),
)
.await
.expect("live bind_websocket must create one official Task before listen");
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the task-capable live tool must return the official Task result branch: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
let mut catalog_with_tasks = modern::SubscriptionFilter {
tools_list_changed: Some(true),
..modern::SubscriptionFilter::default()
};
modern::set_task_subscription_ids(&mut catalog_with_tasks, vec![task_id.clone()])
.expect("the public Tasks filter composes beside a catalog filter");
let catalog_refusal = websocket_client_bounded(
&cx,
"live modern WebSocket catalog listen with taskIds",
client.open_subscriptions_listener(&cx, catalog_with_tasks),
)
.await
.expect_err("catalog listen must refuse taskIds");
assert!(
catalog_refusal
.to_string()
.contains("open_final_task_subscription_listener")
|| catalog_refusal.to_string().contains("taskIds"),
"changing only the added taskIds must keep catalog listen refused: {catalog_refusal:?}"
);
let mut filter = modern::SubscriptionFilter::default();
modern::set_task_subscription_ids(&mut filter, vec![task_id.clone()])
.expect("the public Tasks filter is valid");
websocket_client_bounded(
&cx,
"live modern WebSocket official Tasks listen open",
client.open_final_task_subscription_listener(&cx, filter),
)
.await
.expect("live bind_websocket must admit an incremental official Tasks listener");
let cancellation = McpRequestCancellation::new();
let acknowledgement = websocket_client_bounded(
&cx,
"live modern WebSocket official Tasks listen acknowledgement",
client.next_final_task_subscription_event(&cx, &cancellation),
)
.await
.expect("official Tasks listen must emit its acknowledgement");
assert!(
matches!(
acknowledgement,
modern::StdioTaskSubscriptionEvent::Acknowledged(ref accepted)
if modern::task_subscription_ids(accepted)
.expect("acknowledged Tasks filter stays valid")
.as_deref()
== Some([task_id.clone()].as_slice())
),
"the first incremental Tasks listen record must be the accepted taskIds: {acknowledgement:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket official Tasks listen cancel",
client.cancel_task(&cx, task_id.clone()),
)
.await
.expect("typed tasks/cancel acknowledges the durable cancellation request");
let notification_deadline = Instant::now() + HTTP_OPERATION_BOUND;
loop {
let event = websocket_client_bounded(
&cx,
"live modern WebSocket official Tasks listen status",
client.next_final_task_subscription_event(&cx, &cancellation),
)
.await
.expect("official Tasks listen must retain later status updates");
match event {
modern::StdioTaskSubscriptionEvent::Notification(notification)
if matches!(notification.params.task, FinalTask::Cancelled(_)) =>
{
assert_eq!(
notification.params.task.base().task_id,
task_id,
"the Tasks notification must keep the created id"
);
break;
}
modern::StdioTaskSubscriptionEvent::Notification(_)
| modern::StdioTaskSubscriptionEvent::Acknowledged(_) => {
assert!(
Instant::now() < notification_deadline,
"the caller-owned supervisor publishes cancellation within the public bound"
);
}
modern::StdioTaskSubscriptionEvent::Terminal => {
panic!("the live Tasks listener must retain cancellation before terminal")
}
}
}
let observed = websocket_client_bounded(
&cx,
"live modern WebSocket official Tasks listen get after cancel",
client.get_task(&cx, task_id),
)
.await
.expect("typed tasks/get remains usable after the listener observed cancellation");
assert!(
matches!(observed.task, FinalTask::Cancelled(_)),
"the same session must still admit tasks/get after listen: {observed:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket Tasks listen close",
client.close(&cx),
)
.await
.expect("the modern WebSocket Tasks listen client closes after the live proof");
drop(client);
service.abort();
cx.set_cancel_requested(true);
listener.abort();
});
}
#[cfg(all(feature = "tasks", feature = "proxy"))]
#[test]
fn e2e_public_websocket_catalog_and_tasks_listen_stay_live_on_the_same_client() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket dual-listen runtime installs an ambient context",
);
let task_runtime = FinalTaskRuntime::in_memory(
FinalTaskRuntimeConfig::new(60_000, Some(5_000))
.expect("modern WebSocket dual-listen timing policy is valid"),
Arc::new(|_| {}),
);
let task_runner = task_runtime
.install_task_service(1, Arc::new(PublicHttpHoldingTaskSupervisor))
.expect("modern WebSocket dual-listen service install must succeed");
let server = modern::ServerBuilder::new("facade-ws-dual-listen", "1.0.0")
.tool(PublicHttpTaskTool)
.tool(PublicHttpHideTool)
.final_tasks(task_runtime)
.expect("modern WebSocket final_tasks dual-listen install must succeed")
.build();
let service_scope = cx.scope();
let service = cx
.spawn_in(&service_scope, move |service_cx| async move {
task_runner.run(&service_cx).await
})
.expect("modern WebSocket dual-listen service must be admitted");
asupersync::runtime::yield_now().await;
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket dual listen must bind a localhost listener");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket dual listen publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket dual listen serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket dual listen handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket dual listen must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket dual listen initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-dual-listen", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates dual listen over bind_websocket");
let created = websocket_client_bounded(
&cx,
"live modern WebSocket dual listen tools/call",
client.call_tool_outcome(&cx, PUBLIC_HTTP_TASK_TOOL_NAME, json!({})),
)
.await
.expect("live bind_websocket must create one official Task before dual listen");
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the task-capable live tool must return the official Task result branch: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
let catalog_filter = modern::SubscriptionFilter {
tools_list_changed: Some(true),
..modern::SubscriptionFilter::default()
};
websocket_client_bounded(
&cx,
"live modern WebSocket dual listen catalog open",
client.open_subscriptions_listener(&cx, catalog_filter),
)
.await
.expect("live bind_websocket must admit a catalog listener while official Tasks remain unused");
let cancellation = McpRequestCancellation::new();
let catalog_ack = websocket_client_bounded(
&cx,
"live modern WebSocket dual listen catalog acknowledgement",
client.next_subscription_event(&cx, &cancellation),
)
.await
.expect("catalog listen must emit its acknowledgement");
assert!(
matches!(
catalog_ack,
modern::StdioSubscriptionEvent::Acknowledged(ref filter)
if filter.tools_list_changed == Some(true)
),
"the first catalog listen record must be the accepted filter: {catalog_ack:?}"
);
let mut task_filter = modern::SubscriptionFilter::default();
modern::set_task_subscription_ids(&mut task_filter, vec![task_id.clone()])
.expect("the public Tasks filter is valid");
websocket_client_bounded(
&cx,
"live modern WebSocket dual listen Tasks open",
client.open_final_task_subscription_listener(&cx, task_filter),
)
.await
.expect("the same WebSocketClient must admit official Tasks listen while catalog listen is live");
let task_ack = websocket_client_bounded(
&cx,
"live modern WebSocket dual listen Tasks acknowledgement",
client.next_final_task_subscription_event(&cx, &cancellation),
)
.await
.expect("official Tasks listen must emit its acknowledgement");
assert!(
matches!(
task_ack,
modern::StdioTaskSubscriptionEvent::Acknowledged(ref accepted)
if modern::task_subscription_ids(accepted)
.expect("acknowledged Tasks filter stays valid")
.as_deref()
== Some([task_id.clone()].as_slice())
),
"the first incremental Tasks listen record must be the accepted taskIds: {task_ack:?}"
);
let hidden = websocket_client_bounded(
&cx,
"live modern WebSocket dual listen hide",
client.call_tool(&cx, PUBLIC_HTTP_HIDE_TOOL_NAME, json!({})),
)
.await
.expect("disabling a peer tool must complete while both listeners are live");
assert!(
hidden.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "hidden",
_ => false,
}),
"the same WebSocketClient must still admit tools/call while both listeners are live: {hidden:?}"
);
let changed = websocket_client_bounded(
&cx,
"live modern WebSocket dual listen catalog list_changed",
client.next_subscription_event(&cx, &cancellation),
)
.await
.expect("catalog listen must retain tools/list_changed while Tasks listen is live");
assert!(
matches!(
changed,
modern::StdioSubscriptionEvent::Notification(
modern::ServerNotification::ToolsListChanged(_)
)
),
"live bind_websocket must retain catalog list_changed on the same client as Tasks listen: {changed:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket dual listen cancel",
client.cancel_task(&cx, task_id.clone()),
)
.await
.expect("typed tasks/cancel remains usable while both listeners are live");
let notification_deadline = Instant::now() + HTTP_OPERATION_BOUND;
loop {
let event = websocket_client_bounded(
&cx,
"live modern WebSocket dual listen Tasks status",
client.next_final_task_subscription_event(&cx, &cancellation),
)
.await
.expect("official Tasks listen must retain later status updates while catalog listen is live");
match event {
modern::StdioTaskSubscriptionEvent::Notification(notification)
if matches!(notification.params.task, FinalTask::Cancelled(_)) =>
{
assert_eq!(
notification.params.task.base().task_id,
task_id,
"the Tasks notification must keep the created id"
);
break;
}
modern::StdioTaskSubscriptionEvent::Notification(_)
| modern::StdioTaskSubscriptionEvent::Acknowledged(_) => {
assert!(
Instant::now() < notification_deadline,
"the caller-owned supervisor publishes cancellation within the public bound"
);
}
modern::StdioTaskSubscriptionEvent::Terminal => {
panic!("the live Tasks listener must retain cancellation before terminal")
}
}
}
let observed = websocket_client_bounded(
&cx,
"live modern WebSocket dual listen get after cancel",
client.get_task(&cx, task_id),
)
.await
.expect("typed tasks/get remains usable after both listeners observed their events");
assert!(
matches!(observed.task, FinalTask::Cancelled(_)),
"the same WebSocket client must still admit tasks/get after dual listen: {observed:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket dual listen close",
client.close(&cx),
)
.await
.expect("the modern WebSocket dual-listen client closes after the live proof");
drop(client);
service.abort();
cx.set_cancel_requested(true);
listener.abort();
});
}
#[cfg(all(feature = "tasks", feature = "proxy"))]
#[test]
fn e2e_public_websocket_as_proxy_tasks_listen_retains_status_through_the_gateway() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy Tasks listen runtime installs an ambient context",
);
let upstream = spawn_modern_task_http_server();
let mut creator = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks listen upstream create",
modern::ClientBuilder::new()
.client_info("e2e-ws-as-proxy-tasks-listen-creator", "1.0.0")
.connect_http_with_cx(public_http_target(upstream.address(), "/mcp"), &cx),
)
.await
.expect("the public facade connects to the live Tasks upstream");
let created = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks listen tools/call",
creator.call_tool_outcome(
&cx,
RequestId::Number(2),
PUBLIC_HTTP_TASK_TOOL_NAME,
json!({}),
1 << 20,
),
)
.await
.expect("the live upstream must create one official Task");
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the task-capable live tool must return the official Task result branch: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
drop(creator);
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-tasks-gateway".to_owned(),
"e2e-ws-as-proxy-tasks-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket Tasks gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-tasks-upstream",
"native-h1:e2e-ws-as-proxy-tasks-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-tasks".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP Tasks proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP Tasks proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-tasks-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed Tasks install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy Tasks must bind a localhost listener");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket as_proxy Tasks publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy Tasks serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks listen handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket as_proxy Tasks must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks listen initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-as-proxy-tasks-listen", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy official Tasks over bind_websocket");
let attached = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks listen get",
client.get_task(&cx, task_id.clone()),
)
.await
.expect("as_proxy must attach the upstream-created Task through the WebSocket gateway");
assert_eq!(
attached.task.base().task_id,
task_id,
"as_proxy must return the upstream-created Task: {attached:?}"
);
let mut filter = modern::SubscriptionFilter::default();
modern::set_task_subscription_ids(&mut filter, vec![task_id.clone()])
.expect("the public Tasks filter is valid");
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy official Tasks listen open",
client.open_final_task_subscription_listener(&cx, filter),
)
.await
.expect("as_proxy bind_websocket must admit an incremental official Tasks listener");
let cancellation = McpRequestCancellation::new();
let acknowledgement = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy official Tasks listen acknowledgement",
client.next_final_task_subscription_event(&cx, &cancellation),
)
.await
.expect("as_proxy Tasks listen must emit its acknowledgement");
assert!(
matches!(
acknowledgement,
modern::StdioTaskSubscriptionEvent::Acknowledged(ref accepted)
if modern::task_subscription_ids(accepted)
.expect("acknowledged Tasks filter stays valid")
.as_deref()
== Some([task_id.clone()].as_slice())
),
"the first as_proxy WebSocket Tasks listen record must be the accepted taskIds: {acknowledgement:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy official Tasks listen cancel",
client.cancel_task(&cx, task_id.clone()),
)
.await
.expect("the live as_proxy WebSocket gateway must forward tasks/cancel");
let notification_deadline = Instant::now() + HTTP_OPERATION_BOUND;
loop {
let event = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy official Tasks listen status",
client.next_final_task_subscription_event(&cx, &cancellation),
)
.await
.expect("as_proxy Tasks listen must retain later status updates");
match event {
modern::StdioTaskSubscriptionEvent::Notification(notification)
if matches!(notification.params.task, FinalTask::Cancelled(_)) =>
{
assert_eq!(
notification.params.task.base().task_id,
task_id,
"the as_proxy WebSocket Tasks notification must keep the created id"
);
break;
}
modern::StdioTaskSubscriptionEvent::Notification(_)
| modern::StdioTaskSubscriptionEvent::Acknowledged(_) => {
assert!(
Instant::now() < notification_deadline,
"the as_proxy relay publishes cancellation within the public bound"
);
}
modern::StdioTaskSubscriptionEvent::Terminal => {
panic!(
"the live as_proxy WebSocket Tasks listener must retain cancellation before terminal"
)
}
}
}
let observed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy official Tasks listen get after cancel",
client.get_task(&cx, task_id),
)
.await
.expect("typed tasks/get remains usable after the as_proxy listener observed cancellation");
assert!(
matches!(observed.task, FinalTask::Cancelled(_)),
"the same as_proxy WebSocket session must still admit tasks/get after listen: {observed:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks listen close",
client.close(&cx),
)
.await
.expect("the modern WebSocket as_proxy Tasks listen client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "tasks", feature = "proxy"))]
#[test]
fn e2e_public_websocket_as_proxy_forwards_resource_updated_on_catalog_listen() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy catalog listen runtime installs an ambient context",
);
let upstream = spawn_modern_task_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-resource-updated-gateway".to_owned(),
"e2e-ws-as-proxy-resource-updated-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket catalog listen gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-resource-updated-upstream",
"native-h1:e2e-ws-as-proxy-resource-updated-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-resource-updated".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP catalog proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP catalog proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-resource-updated-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed catalog install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy catalog must bind a localhost listener");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket as_proxy catalog publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy catalog serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog listen handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket as_proxy catalog must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog listen initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-as-proxy-resource-updated", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy catalog listen over bind_websocket");
let silent_touch = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog silent touch",
client.call_tool(&cx, &format!("ext/{PUBLIC_HTTP_TOUCH_TOOL_NAME}"), json!({})),
)
.await
.expect("as_proxy WebSocket touch still completes without a catalog listener");
assert!(
silent_touch.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "silent",
_ => false,
}),
"omitting only catalog listen must keep upstream notify_resource_updated silent: {silent_touch:?}"
);
let refused = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog unprefixed touch",
client.call_tool(&cx, PUBLIC_HTTP_TOUCH_TOOL_NAME, json!({})),
)
.await;
assert!(
refused.is_err(),
"changing only the unprefixed touch name must stay refused: {refused:?}"
);
let filter = modern::SubscriptionFilter {
resource_subscriptions: Some(vec![PUBLIC_HTTP_WATCH_RESOURCE_URI.to_owned()]),
..modern::SubscriptionFilter::default()
};
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog listen open",
client.open_subscriptions_listener(&cx, filter),
)
.await
.expect("as_proxy bind_websocket must admit a catalog listener for the unprefixed resource URI");
let cancellation = McpRequestCancellation::new();
let acknowledgement = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog listen acknowledgement",
client.next_subscription_event(&cx, &cancellation),
)
.await
.expect("as_proxy catalog listen must emit its acknowledgement");
assert!(
matches!(
acknowledgement,
modern::StdioSubscriptionEvent::Acknowledged(_)
),
"the first as_proxy WebSocket catalog listen record must be the accepted filter: {acknowledgement:?}"
);
let touched = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog listen touch",
client.call_tool(&cx, &format!("ext/{PUBLIC_HTTP_TOUCH_TOOL_NAME}"), json!({})),
)
.await
.expect("as_proxy WebSocket must forward the prefixed touch tool");
assert!(
touched.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "notified",
_ => false,
}),
"an inbound catalog listen must count as upstream notify_resource_updated delivery: {touched:?}"
);
let updated = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog listen updated",
client.next_subscription_event(&cx, &cancellation),
)
.await
.expect("as_proxy catalog listen must retain the relayed resources/updated");
assert!(
matches!(
updated,
modern::StdioSubscriptionEvent::Notification(
modern::ServerNotification::ResourceUpdated(ref params)
) if params.uri.as_str() == PUBLIC_HTTP_WATCH_RESOURCE_URI
),
"as_proxy WebSocket must relay notifications/resources/updated onto the inbound catalog listen: {updated:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog listen close",
client.close(&cx),
)
.await
.expect("the modern WebSocket as_proxy catalog listen client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "tasks", feature = "proxy"))]
#[test]
fn e2e_public_websocket_as_proxy_catalog_and_tasks_listen_stay_live_on_the_same_client() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy dual-listen runtime installs an ambient context",
);
let upstream = spawn_modern_task_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-dual-listen-gateway".to_owned(),
"e2e-ws-as-proxy-dual-listen-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket dual-listen gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-dual-listen-upstream",
"native-h1:e2e-ws-as-proxy-dual-listen-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-dual-listen".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP dual-listen proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP dual-listen proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-dual-listen-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed dual-listen install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy dual listen must bind a localhost listener");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket as_proxy dual listen publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy dual listen serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy dual listen handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket as_proxy dual listen must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy dual listen initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-as-proxy-dual-listen", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy dual listen over bind_websocket");
let created = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy dual listen create",
client.call_tool_outcome(
&cx,
&format!("ext/{PUBLIC_HTTP_TASK_TOOL_NAME}"),
json!({}),
),
)
.await
.expect("as_proxy WebSocket must create one official Task through the gateway");
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the prefixed task-capable tool must return the official Task result branch: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
let catalog_filter = modern::SubscriptionFilter {
resource_subscriptions: Some(vec![PUBLIC_HTTP_WATCH_RESOURCE_URI.to_owned()]),
..modern::SubscriptionFilter::default()
};
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy dual listen catalog open",
client.open_subscriptions_listener(&cx, catalog_filter),
)
.await
.expect("as_proxy bind_websocket must admit a catalog listener while official Tasks remain unused");
let cancellation = McpRequestCancellation::new();
let catalog_ack = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy dual listen catalog acknowledgement",
client.next_subscription_event(&cx, &cancellation),
)
.await
.expect("as_proxy catalog listen must emit its acknowledgement");
assert!(
matches!(
catalog_ack,
modern::StdioSubscriptionEvent::Acknowledged(_)
),
"the first as_proxy WebSocket catalog listen record must be the accepted filter: {catalog_ack:?}"
);
let mut task_filter = modern::SubscriptionFilter::default();
modern::set_task_subscription_ids(&mut task_filter, vec![task_id.clone()])
.expect("the public Tasks filter is valid");
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy dual listen Tasks open",
client.open_final_task_subscription_listener(&cx, task_filter),
)
.await
.expect("the same as_proxy WebSocketClient must admit official Tasks listen while catalog listen is live");
let task_ack = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy dual listen Tasks acknowledgement",
client.next_final_task_subscription_event(&cx, &cancellation),
)
.await
.expect("as_proxy official Tasks listen must emit its acknowledgement");
assert!(
matches!(
task_ack,
modern::StdioTaskSubscriptionEvent::Acknowledged(ref accepted)
if modern::task_subscription_ids(accepted)
.expect("acknowledged Tasks filter stays valid")
.as_deref()
== Some([task_id.clone()].as_slice())
),
"the first as_proxy WebSocket Tasks listen record must be the accepted taskIds: {task_ack:?}"
);
let touched = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy dual listen touch",
client.call_tool(&cx, &format!("ext/{PUBLIC_HTTP_TOUCH_TOOL_NAME}"), json!({})),
)
.await
.expect("as_proxy WebSocket touch must complete while both listeners are live");
assert!(
touched.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "notified",
_ => false,
}),
"an inbound catalog listen must count as upstream notify_resource_updated delivery: {touched:?}"
);
let updated = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy dual listen catalog updated",
client.next_subscription_event(&cx, &cancellation),
)
.await
.expect("as_proxy catalog listen must retain resources/updated while Tasks listen is live");
assert!(
matches!(
updated,
modern::StdioSubscriptionEvent::Notification(
modern::ServerNotification::ResourceUpdated(ref params)
) if params.uri.as_str() == PUBLIC_HTTP_WATCH_RESOURCE_URI
),
"as_proxy WebSocket dual listen must retain resources/updated on the catalog listener: {updated:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy dual listen cancel",
client.cancel_task(&cx, task_id.clone()),
)
.await
.expect("typed tasks/cancel remains usable while both as_proxy listeners are live");
let notification_deadline = Instant::now() + HTTP_OPERATION_BOUND;
loop {
let event = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy dual listen Tasks status",
client.next_final_task_subscription_event(&cx, &cancellation),
)
.await
.expect("as_proxy Tasks listen must retain later status updates while catalog listen is live");
match event {
modern::StdioTaskSubscriptionEvent::Notification(notification)
if matches!(notification.params.task, FinalTask::Cancelled(_)) =>
{
assert_eq!(
notification.params.task.base().task_id,
task_id,
"the as_proxy WebSocket Tasks notification must keep the created id"
);
break;
}
modern::StdioTaskSubscriptionEvent::Notification(_)
| modern::StdioTaskSubscriptionEvent::Acknowledged(_) => {
assert!(
Instant::now() < notification_deadline,
"the as_proxy relay publishes cancellation within the public bound"
);
}
modern::StdioTaskSubscriptionEvent::Terminal => {
panic!(
"the live as_proxy WebSocket Tasks listener must retain cancellation before terminal"
)
}
}
}
let observed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy dual listen get after cancel",
client.get_task(&cx, task_id),
)
.await
.expect("typed tasks/get remains usable after both as_proxy listeners observed their events");
assert!(
matches!(observed.task, FinalTask::Cancelled(_)),
"the same as_proxy WebSocket client must still admit tasks/get after dual listen: {observed:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy dual listen close",
client.close(&cx),
)
.await
.expect("the modern WebSocket as_proxy dual-listen client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "tasks", feature = "proxy"))]
#[test]
fn e2e_public_websocket_as_proxy_creates_official_task_through_the_gateway() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy Tasks create runtime installs an ambient context",
);
let upstream = spawn_modern_task_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-tasks-create-gateway".to_owned(),
"e2e-ws-as-proxy-tasks-create-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket Tasks create gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-tasks-create-upstream",
"native-h1:e2e-ws-as-proxy-tasks-create-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-tasks-create".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP Tasks proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP Tasks proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-tasks-create-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed Tasks install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy Tasks create must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket as_proxy Tasks create publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy Tasks create serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks create handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket as_proxy Tasks create must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks create initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-as-proxy-tasks-create", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy official Tasks create over bind_websocket");
let prefixed = format!("ext/{PUBLIC_HTTP_TASK_TOOL_NAME}");
let listed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks create list_tools",
client.list_tools(&cx, None),
)
.await
.expect("the live as_proxy WebSocket gateway must advertise the prefixed Tasks tool");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed),
"as_proxy_typed must prefix the live Tasks tool over WebSocket: {listed:?}"
);
assert!(
!listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TASK_TOOL_NAME),
"a nonempty as_proxy prefix must not keep the unprefixed Tasks tool over WebSocket: {listed:?}"
);
let unprefixed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks create unprefixed",
client.call_tool_outcome(&cx, PUBLIC_HTTP_TASK_TOOL_NAME, json!({})),
)
.await
.expect_err("changing only the tool name must not create a Task on the unprefixed path");
let _ = unprefixed;
let created = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks create tools/call",
client.call_tool_outcome(&cx, &prefixed, json!({})),
)
.await
.expect("the live as_proxy WebSocket gateway must create one official Task through the prefixed tool");
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the prefixed task-capable live tool must return the official Task result branch: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
let observed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks create get",
client.get_task(&cx, task_id.clone()),
)
.await
.expect("typed tasks/get must return the WebSocket-gateway-created Task");
assert_eq!(
observed.task.base().task_id,
task_id,
"as_proxy WebSocket must keep the same id it created: {observed:?}"
);
let missing = FinalTaskId::parse("missing-ws-gateway-created-task")
.expect("the planted-missing task id is a valid official TaskId");
let missing_get = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks create missing get",
client.get_task(&cx, missing),
)
.await
.expect_err("changing only the task id must not invent a Task on the as_proxy WebSocket gateway");
let _ = missing_get;
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks create close",
client.close(&cx),
)
.await
.expect("the modern WebSocket as_proxy Tasks create client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_forwards_inbound_client_implementation() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy identity runtime installs an ambient context",
);
let upstream = spawn_modern_client_identity_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-identity-gateway".to_owned(),
"e2e-ws-as-proxy-identity-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket identity gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-identity-upstream",
"native-h1:e2e-ws-as-proxy-identity-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-identity".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP identity proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP identity proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-identity-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed identity install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy identity must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket as_proxy identity publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy identity serve must be admitted");
let identified_transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy identity handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("identified as_proxy WebSocket must complete RFC 6455 upgrade");
let mut identified = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy identity initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-identity", "1.0.0")
.title("Caller Title")
.connect_websocket_with_cx(&cx, identified_transport),
)
.await
.expect("the identified ModernOnly public facade negotiates as_proxy identity over bind_websocket");
let bare_transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy identity bare handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("bare as_proxy WebSocket must complete RFC 6455 upgrade");
let mut bare = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy identity bare initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-identity-bare", "1.0.0")
.connect_websocket_with_cx(&cx, bare_transport),
)
.await
.expect("the bare ModernOnly public facade negotiates as_proxy identity over bind_websocket");
let prefixed = format!("ext/{PUBLIC_HTTP_CLIENT_IDENTITY_TOOL_NAME}");
let listed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy identity list_tools",
identified.list_tools(&cx, None),
)
.await
.expect("the live as_proxy WebSocket gateway must advertise the prefixed identity tool");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed),
"as_proxy_typed must prefix the live identity tool over WebSocket: {listed:?}"
);
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy identity unprefixed",
identified.call_tool(&cx, PUBLIC_HTTP_CLIENT_IDENTITY_TOOL_NAME, json!({})),
)
.await
.expect_err("changing only the tool name must not reach the unprefixed identity handler");
let _ = missing;
let identified_result = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy identity tools/call",
identified.call_tool(&cx, &prefixed, json!({})),
)
.await
.expect("as_proxy WebSocket must forward the prefixed identity tool");
assert!(
identified_result
.content
.iter()
.any(|content| match content {
ContentBlock::Text { text, .. } => {
text == "name=e2e-as-proxy-ws-identity|title=Caller Title"
}
_ => false,
}),
"as_proxy WebSocket must stamp the inbound ClientBuilder title onto the upstream handler: {identified_result:?}"
);
let bare_result = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy identity bare tools/call",
bare.call_tool(&cx, &prefixed, json!({})),
)
.await
.expect("as_proxy WebSocket must still forward the prefixed identity tool for a bare inbound client");
assert!(
bare_result.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text == "name=e2e-as-proxy-ws-identity-bare|title=none"
}
_ => false,
}),
"changing only the missing inbound title must keep the upstream handler extras bare: {bare_result:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy identity close",
identified.close(&cx),
)
.await
.expect("the identified as_proxy WebSocket client closes after the live proof");
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy identity bare close",
bare.close(&cx),
)
.await
.expect("the bare as_proxy WebSocket client closes after the live proof");
drop(identified);
drop(bare);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_adopts_upstream_implementation() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy server-identity runtime installs an ambient context",
);
let identified_upstream = spawn_modern_identity_http_server(true);
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(identified_upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-server-identity-gateway".to_owned(),
"e2e-ws-as-proxy-server-identity-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket server-identity gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-server-identity-upstream",
"native-h1:e2e-ws-as-proxy-server-identity-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-server-identity".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP identity proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP identity proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-identity-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed server-identity install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy server-identity must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy server-identity publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy server-identity serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy server-identity handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket server-identity must complete RFC 6455 upgrade");
let client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy server-identity initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-server-identity", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy server-identity over bind_websocket");
let discovered = client
.session()
.server_discovery()
.expect("modern WebSocket as_proxy discovery exposes handshake identity");
let implementation = discovered.implementation().expect(
"modern WebSocket as_proxy must adopt the upstream Implementation extras onto gateway discovery",
);
assert_eq!(implementation.title.as_deref(), Some("Identity Title"));
assert_eq!(
implementation.description.as_deref(),
Some("Identity description")
);
assert_eq!(
implementation.website_url.as_ref().map(|uri| uri.as_str()),
Some("https://example.test/fastmcp")
);
assert_eq!(
implementation.icons.first().map(|icon| icon.src.as_str()),
Some("https://example.test/e2e-icon.png"),
"as_proxy WebSocket must retain the upstream icon: {implementation:?}"
);
assert_eq!(
discovered
.server_info()
.map(|info| (info.name.as_str(), info.version.as_str())),
Some(("e2e-ws-as-proxy-identity-gateway", "1.0.0")),
"as_proxy WebSocket must keep the gateway name/version while adopting extras"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
identified_upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_bare_upstream_omits_implementation() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy bare-identity runtime installs an ambient context",
);
let bare_upstream = spawn_modern_identity_http_server(false);
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(bare_upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-bare-identity-gateway".to_owned(),
"e2e-ws-as-proxy-bare-identity-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket bare-identity gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-bare-identity-upstream",
"native-h1:e2e-ws-as-proxy-bare-identity-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-bare-identity".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP bare-identity proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP bare-identity proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-identity-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed bare-identity install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy bare-identity must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy bare-identity publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy bare-identity serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy bare-identity handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket bare-identity must complete RFC 6455 upgrade");
let client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy bare-identity initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-server-identity-bare", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy bare-identity over bind_websocket");
let discovered = client
.session()
.server_discovery()
.expect("modern WebSocket as_proxy discovery exposes the missing-identity observable");
assert!(
discovered.implementation().is_none(),
"changing only the missing upstream extras must keep the as_proxy WebSocket gateway name/version-only: {:?}",
discovered.implementation()
);
assert_eq!(
discovered
.server_info()
.map(|info| (info.name.as_str(), info.version.as_str())),
Some(("e2e-ws-as-proxy-identity-gateway", "1.0.0")),
"the bare as_proxy WebSocket gateway must still advertise its own name and version"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
bare_upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_adopts_upstream_instructions() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy instructions runtime installs an ambient context",
);
let instructed_upstream = spawn_modern_instructions_http_server(true);
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(instructed_upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-instructions-gateway".to_owned(),
"e2e-ws-as-proxy-instructions-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket instructions gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-instructions-upstream",
"native-h1:e2e-ws-as-proxy-instructions-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-instructions".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP instructions proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP instructions proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-instructions-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed instructions install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy instructions must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy instructions publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy instructions serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy instructions handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket instructions must complete RFC 6455 upgrade");
let client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy instructions initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-server-instructions", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy instructions over bind_websocket");
assert_eq!(
client.session().instructions(),
Some(PUBLIC_HTTP_INSTRUCTIONS),
"modern WebSocket as_proxy must adopt the upstream discover instructions"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
instructed_upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_bare_upstream_omits_instructions() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy bare-instructions runtime installs an ambient context",
);
let bare_upstream = spawn_modern_instructions_http_server(false);
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(bare_upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-bare-instructions-gateway".to_owned(),
"e2e-ws-as-proxy-bare-instructions-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket bare-instructions gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-bare-instructions-upstream",
"native-h1:e2e-ws-as-proxy-bare-instructions-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-bare-instructions".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP bare-instructions proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP bare-instructions proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-instructions-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed bare-instructions install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy bare-instructions must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy bare-instructions publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy bare-instructions serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy bare-instructions handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket bare-instructions must complete RFC 6455 upgrade");
let client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy bare-instructions initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-server-instructions-bare", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy bare-instructions over bind_websocket");
assert_eq!(
client.session().instructions(),
None,
"changing only the missing upstream instructions must keep the as_proxy WebSocket gateway bare"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
bare_upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_forwards_hide_as_request_local_on_stateless_http() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket as_proxy hide runtime installs an ambient context");
let hide_upstream = spawn_modern_hide_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(hide_upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-hide-gateway".to_owned(),
"e2e-ws-as-proxy-hide-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket hide gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-hide-upstream",
"native-h1:e2e-ws-as-proxy-hide-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-hide".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP hide proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP hide proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-hide-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed hide install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy hide must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket as_proxy hide publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy hide serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy hide handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket hide must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy hide initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-hide", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy hide over bind_websocket");
let prefixed_hide = format!("ext/{PUBLIC_HTTP_HIDE_TOOL_NAME}");
let prefixed_touch = format!("ext/{PUBLIC_HTTP_TOUCH_TOOL_NAME}");
let prefixed_fast = format!("ext/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let listed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy hide list_tools",
client.list_tools(&cx, None),
)
.await
.expect("the live as_proxy WebSocket gateway must advertise the prefixed hide catalog");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed_hide)
&& listed.tools.iter().any(|tool| tool.name == prefixed_touch)
&& listed.tools.iter().any(|tool| tool.name == prefixed_fast),
"as_proxy_typed must prefix the live hide/touch/fast tools over WebSocket: {listed:?}"
);
let before = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy hide touch before",
client.call_tool(&cx, &prefixed_touch, json!({})),
)
.await
.expect("as_proxy WebSocket must forward the prefixed touch tool before hide");
assert!(
!before.is_error,
"changing only the missing hide must keep prefixed touch admitted: {before:?}"
);
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy hide unprefixed",
client.call_tool(&cx, PUBLIC_HTTP_HIDE_TOOL_NAME, json!({})),
)
.await
.expect_err("changing only the tool name must not reach the unprefixed hide handler");
let _ = missing;
let hidden = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy hide tools/call",
client.call_tool(&cx, &prefixed_hide, json!({})),
)
.await
.expect("as_proxy WebSocket must forward the prefixed hide tool");
assert!(
hidden.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "hidden",
_ => false,
}),
"as_proxy WebSocket must retain the same-POST upstream disable_tool mutation: {hidden:?}"
);
let still_listed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy hide list after",
client.list_tools(&cx, None),
)
.await
.expect("the live as_proxy WebSocket gateway must keep its install-time catalog after hide");
assert!(
still_listed
.tools
.iter()
.any(|tool| tool.name == prefixed_touch),
"as_proxy WebSocket must not drop the prefixed touch tool from the gateway snapshot: {still_listed:?}"
);
let later = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy hide touch after",
client.call_tool(&cx, &prefixed_touch, json!({})),
)
.await
.expect("a later modern HTTP POST of prefixed touch must still complete");
assert!(
!later.is_error,
"modern WebSocket as_proxy hide is request-local on the HTTP upstream: a later call must still admit prefixed touch: {later:?}"
);
assert!(
later.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "silent",
_ => false,
}),
"a later call of prefixed touch must still run the upstream handler (silent, no listener): {later:?}"
);
let peer = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy hide peer",
client.call_tool(&cx, &prefixed_fast, json!({})),
)
.await
.expect("changing only the tool name must still reach an undisabled HTTP handler");
assert!(
peer.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"as_proxy WebSocket must keep undisabled HTTP tools callable after hide: {peer:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
hide_upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_composes_nested_tool_and_resource() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy compose runtime installs an ambient context",
);
let compose_upstream = spawn_modern_compose_and_state_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(compose_upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-compose-gateway".to_owned(),
"e2e-ws-as-proxy-compose-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket compose gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-compose-upstream",
"native-h1:e2e-ws-as-proxy-compose-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-compose".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP compose proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP compose proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-compose-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed compose install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy compose must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket as_proxy compose publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy compose serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy compose handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket compose must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy compose initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-compose", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy compose over bind_websocket");
let prefixed = format!("ext/{PUBLIC_HTTP_COMPOSE_TOOL_NAME}");
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy compose unprefixed",
client.call_tool(
&cx,
PUBLIC_HTTP_COMPOSE_TOOL_NAME,
json!({"value": "alpha"}),
),
)
.await
.expect_err("changing only the tool name must not reach the unprefixed compose handler");
let _ = missing;
let composed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy compose tools/call",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("as_proxy WebSocket must forward prefixed compose onto the live HTTP upstream");
assert!(
composed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text == "compose:tool:alpha|resource:deterministic"
}
_ => false,
}),
"as_proxy WebSocket must retain the nested tool text and resource: {composed:?}"
);
let missing_tool = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy compose missing nested tool",
client.call_tool(
&cx,
&prefixed,
json!({"value": "alpha", "tool": "public-http-e2e-missing"}),
),
)
.await;
let missing_tool = match missing_tool {
Ok(result) => {
assert!(
result.is_error,
"changing only the nested tool name must stay a handler-visible refusal: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("compose-nested-tool"),
"as_proxy WebSocket compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
compose_upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_composes_nested_prompt() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy compose-prompt runtime installs an ambient context",
);
let compose_upstream = spawn_modern_compose_prompt_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(compose_upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-compose-prompt-gateway".to_owned(),
"e2e-ws-as-proxy-compose-prompt-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket compose-prompt gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-compose-prompt-upstream",
"native-h1:e2e-ws-as-proxy-compose-prompt-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-compose-prompt".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP compose-prompt proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP compose-prompt proxy catalog is typed");
let server =
modern::ServerBuilder::new("e2e-ws-as-proxy-compose-prompt-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed compose-prompt install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy compose-prompt must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy compose-prompt publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy compose-prompt serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy compose-prompt handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket compose-prompt must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy compose-prompt initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-compose-prompt", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy compose-prompt over bind_websocket",
);
let prefixed = format!("ext/{PUBLIC_HTTP_PROMPT_COMPOSE_TOOL_NAME}");
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy compose-prompt unprefixed",
client.call_tool(
&cx,
PUBLIC_HTTP_PROMPT_COMPOSE_TOOL_NAME,
json!({"subject": "alpha"}),
),
)
.await
.expect_err(
"changing only the tool name must not reach the unprefixed compose_prompt handler",
);
let _ = missing;
let composed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy compose-prompt tools/call",
client.call_tool(&cx, &prefixed, json!({"subject": "alpha"})),
)
.await
.expect("as_proxy WebSocket must forward prefixed compose_prompt onto the live HTTP upstream");
assert!(
composed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "compose-prompt:prompt:alpha",
_ => false,
}),
"as_proxy WebSocket must retain the nested prompt text: {composed:?}"
);
let missing_prompt = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy compose-prompt missing nested prompt",
client.call_tool(
&cx,
&prefixed,
json!({
"subject": "alpha",
"prompt": "public-http-e2e-missing",
}),
),
)
.await;
let missing_prompt = match missing_prompt {
Ok(result) => {
assert!(
result.is_error,
"changing only the nested prompt name must stay a handler-visible refusal: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("public-http-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy WebSocket compose_prompt must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
compose_upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_resource_composes_nested_tool_and_resource() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy resource-compose runtime installs an ambient context",
);
let compose_upstream = spawn_modern_compose_and_state_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(compose_upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-resource-compose-gateway".to_owned(),
"e2e-ws-as-proxy-resource-compose-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket resource-compose gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-resource-compose-upstream",
"native-h1:e2e-ws-as-proxy-resource-compose-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-resource-compose".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP compose proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP compose proxy catalog is typed");
let server =
modern::ServerBuilder::new("e2e-ws-as-proxy-resource-compose-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed resource-compose install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy resource-compose must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy resource-compose publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy resource-compose serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy resource-compose handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket resource-compose must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy resource-compose initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-resource-compose", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy resource-compose over bind_websocket",
);
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy resource-compose prefixed",
client.read_resource(&cx, &format!("ext/{PUBLIC_HTTP_COMPOSE_RESOURCE_URI}")),
)
.await
.expect_err("modern as_proxy must keep the compose resource URI unprefixed");
let _ = missing;
let composed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy resource-compose resources/read",
client.read_resource(&cx, PUBLIC_HTTP_COMPOSE_RESOURCE_URI),
)
.await
.expect("as_proxy WebSocket must forward unprefixed compose resources/read onto the live HTTP upstream");
assert!(
composed.contents.iter().any(|content| match content {
EmbeddedResourceContents::Text { text, .. } => {
text == "compose:tool:alpha|resource:deterministic"
}
_ => false,
}),
"as_proxy WebSocket resources/read must retain the nested tool text and resource: {composed:?}"
);
let missing_tool = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy resource-compose missing nested tool",
client.read_resource(&cx, PUBLIC_HTTP_COMPOSE_MISSING_TOOL_RESOURCE_URI),
)
.await;
let missing_tool = match missing_tool {
Ok(result) => panic!(
"changing only the nested tool name must stay a handler-visible refusal: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("compose-nested-tool"),
"as_proxy WebSocket resource compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
compose_upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_prompt_composes_nested_tool_and_resource() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy prompt-compose runtime installs an ambient context",
);
let compose_upstream = spawn_modern_compose_and_state_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(compose_upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-prompt-compose-gateway".to_owned(),
"e2e-ws-as-proxy-prompt-compose-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket prompt-compose gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-prompt-compose-upstream",
"native-h1:e2e-ws-as-proxy-prompt-compose-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-prompt-compose".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP compose proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP compose proxy catalog is typed");
let server =
modern::ServerBuilder::new("e2e-ws-as-proxy-prompt-compose-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed prompt-compose install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy prompt-compose must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy prompt-compose publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy prompt-compose serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy prompt-compose handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket prompt-compose must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy prompt-compose initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-prompt-compose-handler", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy prompt-compose over bind_websocket",
);
let prefixed = format!("ext/{PUBLIC_HTTP_COMPOSE_PROMPT_NAME}");
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy prompt-compose unprefixed",
client.get_prompt(
&cx,
PUBLIC_HTTP_COMPOSE_PROMPT_NAME,
HashMap::from([("value".to_owned(), "alpha".to_owned())]),
),
)
.await
.expect_err("changing only the prompt name must not reach the unprefixed compose prompt");
let _ = missing;
let composed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy prompt-compose prompts/get",
client.get_prompt(
&cx,
&prefixed,
HashMap::from([("value".to_owned(), "alpha".to_owned())]),
),
)
.await
.expect("as_proxy WebSocket must forward prefixed compose prompts/get onto the live HTTP upstream");
assert!(
composed
.messages
.iter()
.any(|message| match &message.content {
ContentBlock::Text { text, .. } => {
text == "compose:tool:alpha|resource:deterministic"
}
_ => false,
}),
"as_proxy WebSocket prompts/get must retain the nested tool text and resource: {composed:?}"
);
let missing_tool = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy prompt-compose missing nested tool",
client.get_prompt(
&cx,
&prefixed,
HashMap::from([
("value".to_owned(), "alpha".to_owned()),
("tool".to_owned(), "public-http-e2e-missing".to_owned()),
]),
),
)
.await;
let missing_tool = match missing_tool {
Ok(result) => panic!(
"changing only the nested tool name must stay a handler-visible refusal: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("compose-nested-tool"),
"as_proxy WebSocket prompt compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
compose_upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_resource_composes_nested_prompt() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy resource-compose-prompt runtime installs an ambient context",
);
let compose_upstream = spawn_modern_compose_prompt_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(compose_upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-resource-compose-prompt-gateway".to_owned(),
"e2e-ws-as-proxy-resource-compose-prompt-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket resource-compose-prompt gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-resource-compose-prompt-upstream",
"native-h1:e2e-ws-as-proxy-resource-compose-prompt-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-resource-compose-prompt".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP compose-prompt proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP compose-prompt proxy catalog is typed");
let server = modern::ServerBuilder::new(
"e2e-ws-as-proxy-resource-compose-prompt-gateway",
"1.0.0",
)
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed resource-compose-prompt install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy resource-compose-prompt must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy resource-compose-prompt publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy resource-compose-prompt serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy resource-compose-prompt handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket resource-compose-prompt must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy resource-compose-prompt initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-resource-compose-prompt", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy resource-compose-prompt over bind_websocket",
);
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy resource-compose-prompt prefixed",
client.read_resource(
&cx,
&format!("ext/{PUBLIC_HTTP_PROMPT_COMPOSE_RESOURCE_URI}"),
),
)
.await
.expect_err("modern as_proxy must keep the compose-prompt resource URI unprefixed");
let _ = missing;
let composed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy resource-compose-prompt resources/read",
client.read_resource(&cx, PUBLIC_HTTP_PROMPT_COMPOSE_RESOURCE_URI),
)
.await
.expect("as_proxy WebSocket must forward unprefixed compose-prompt resources/read onto the live HTTP upstream");
assert!(
composed.contents.iter().any(|content| match content {
EmbeddedResourceContents::Text { text, .. } => {
text == "compose-prompt:prompt:alpha"
}
_ => false,
}),
"as_proxy WebSocket resources/read must retain the nested prompt text: {composed:?}"
);
let missing_prompt = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy resource-compose-prompt missing nested prompt",
client.read_resource(&cx, PUBLIC_HTTP_PROMPT_COMPOSE_MISSING_RESOURCE_URI),
)
.await;
let missing_prompt = match missing_prompt {
Ok(result) => panic!(
"changing only the nested prompt name must stay a handler-visible refusal: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("public-http-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy WebSocket resource compose-prompt must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
compose_upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_from_prompt_composes_nested_prompt() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy from-prompt runtime installs an ambient context",
);
let compose_upstream = spawn_modern_compose_prompt_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(compose_upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-from-prompt-gateway".to_owned(),
"e2e-ws-as-proxy-from-prompt-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket from-prompt gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-from-prompt-upstream",
"native-h1:e2e-ws-as-proxy-from-prompt-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-from-prompt".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP from-prompt proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP from-prompt proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-from-prompt-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed from-prompt install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy from-prompt must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy from-prompt publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy from-prompt serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy from-prompt handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket from-prompt must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy from-prompt initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-from-prompt", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy from-prompt over bind_websocket",
);
let prefixed = format!("ext/{PUBLIC_HTTP_FROM_PROMPT_NAME}");
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy from-prompt unprefixed",
client.get_prompt(
&cx,
PUBLIC_HTTP_FROM_PROMPT_NAME,
HashMap::from([("subject".to_owned(), "alpha".to_owned())]),
),
)
.await
.expect_err("changing only the prompt name must not reach the unprefixed from-prompt");
let _ = missing;
let composed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy from-prompt prompts/get",
client.get_prompt(
&cx,
&prefixed,
HashMap::from([("subject".to_owned(), "alpha".to_owned())]),
),
)
.await
.expect("as_proxy WebSocket must forward prefixed from-prompt onto the live HTTP upstream");
assert!(
composed
.messages
.iter()
.any(|message| match &message.content {
ContentBlock::Text { text, .. } => text == "compose-prompt:prompt:alpha",
_ => false,
}),
"as_proxy WebSocket prompts/get must retain the nested prompt text: {composed:?}"
);
let missing_prompt = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy from-prompt missing nested prompt",
client.get_prompt(
&cx,
&prefixed,
HashMap::from([
("subject".to_owned(), "alpha".to_owned()),
("prompt".to_owned(), "public-http-e2e-missing".to_owned()),
]),
),
)
.await;
let missing_prompt = match missing_prompt {
Ok(result) => panic!(
"changing only the nested prompt name must stay a handler-visible refusal: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("public-http-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy WebSocket from-prompt compose must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
compose_upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_request_timeout_of_hold_tool_without_handler_timeout() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy request_timeout runtime installs an ambient context",
);
let hold_upstream = spawn_modern_as_proxy_hold_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(hold_upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-request-timeout-gateway".to_owned(),
"e2e-ws-as-proxy-request-timeout-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket request_timeout gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-request-timeout-upstream",
"native-h1:e2e-ws-as-proxy-request-timeout-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-request-timeout".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP hold proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP hold proxy catalog is typed");
let server = modern::ServerBuilder::new(
"e2e-ws-as-proxy-request-timeout-gateway",
"1.0.0",
)
.request_timeout(1)
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed request_timeout install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy request_timeout must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy request_timeout publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy request_timeout serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy request_timeout handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket request_timeout must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy request_timeout initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-request-timeout", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy request_timeout over bind_websocket",
);
let prefixed_hold = format!("ext/{PUBLIC_HTTP_HOLD_TOOL_NAME}");
let prefixed_fast = format!("ext/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy request_timeout unprefixed",
client.call_tool(&cx, PUBLIC_HTTP_HOLD_TOOL_NAME, json!({})),
)
.await
.expect_err("changing only the tool name must not reach the unprefixed hold handler");
let _ = missing;
let timed_out = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy request_timeout hold",
client.call_tool(&cx, &prefixed_hold, json!({})),
)
.await;
let timed_out = match timed_out {
Ok(result) => {
assert!(
result.is_error,
"a hold tool without timeout() must stay an error under gateway request_timeout: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
timed_out.contains("Request timeout exceeded")
|| timed_out.contains("RequestCancelled")
|| timed_out.contains("Request cancelled"),
"gateway request_timeout(1) must refuse prefixed hold before it returns held: {timed_out}"
);
assert!(
!timed_out.contains("held"),
"changing only the missing handler timeout must not let hold complete: {timed_out}"
);
let peer = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy request_timeout peer",
client.call_tool(&cx, &prefixed_fast, json!({})),
)
.await
.expect("changing only the tool name must still reach the undisabled fast handler");
assert!(
peer.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"as_proxy WebSocket must keep the prefixed fast peer callable after a request_timeout: {peer:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
hold_upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_mask_error_details_hides_upstream_resource_secret() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy mask runtime installs an ambient context",
);
let leak_upstream = spawn_modern_as_proxy_leak_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(leak_upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-mask-gateway".to_owned(),
"e2e-ws-as-proxy-mask-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket mask gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-mask-upstream",
"native-h1:e2e-ws-as-proxy-mask-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-mask".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP leak proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP leak proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-mask-gateway", "1.0.0")
.mask_error_details(true)
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed mask install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy mask must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket as_proxy mask publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy mask serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy mask handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket mask must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy mask initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-mask", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy mask over bind_websocket");
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy mask prefixed leak",
client.read_resource(&cx, &format!("ext/{PUBLIC_HTTP_LEAK_RESOURCE_URI}")),
)
.await
.expect_err("modern as_proxy must keep the leak resource URI unprefixed");
let _ = missing;
let masked = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy mask leak read",
client.read_resource(&cx, PUBLIC_HTTP_LEAK_RESOURCE_URI),
)
.await
.expect_err("a leaking upstream resource must stay a resources/read error through as_proxy WebSocket");
let masked = format!("{masked:?}");
assert!(
masked.contains("Internal server error"),
"gateway mask_error_details must replace the upstream execution secret over WebSocket: {masked}"
);
assert!(
!masked.contains(PUBLIC_HTTP_LEAK_SECRET),
"gateway mask_error_details must not leak the upstream execution secret over WebSocket: {masked}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
leak_upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_forwards_handler_timeout_of_prefixed_slow_tool() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy timeout runtime installs an ambient context",
);
let timeout_upstream = spawn_modern_handler_timeout_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(timeout_upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-timeout-gateway".to_owned(),
"e2e-ws-as-proxy-timeout-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket timeout gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-timeout-upstream",
"native-h1:e2e-ws-as-proxy-timeout-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-timeout".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP timeout proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP timeout proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-timeout-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed timeout install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy timeout must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket as_proxy timeout publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy timeout serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy timeout handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket timeout must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy timeout initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-timeout", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy timeout over bind_websocket");
let prefixed_slow = format!("ext/{PUBLIC_HTTP_SLOW_TOOL_NAME}");
let prefixed_fast = format!("ext/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy timeout unprefixed",
client.call_tool(&cx, PUBLIC_HTTP_SLOW_TOOL_NAME, json!({})),
)
.await
.expect_err("changing only the tool name must not reach the unprefixed slow handler");
let _ = missing;
let timed_out = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy timeout slow",
client.call_tool(&cx, &prefixed_slow, json!({})),
)
.await;
let timed_out = match timed_out {
Ok(result) => {
assert!(
result.is_error,
"a handler that outlives its timeout must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
timed_out.contains("Request timeout exceeded")
|| timed_out.contains("RequestCancelled")
|| timed_out.contains("Request cancelled"),
"the refused prefixed slow tools/call must keep the handler-timeout error over WebSocket: {timed_out}"
);
let peer = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy timeout peer",
client.call_tool(&cx, &prefixed_fast, json!({})),
)
.await
.expect("changing only the tool name must still reach the undisabled fast handler");
assert!(
peer.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"as_proxy WebSocket must keep the prefixed fast peer callable after a handler timeout: {peer:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
timeout_upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_sanitizes_prefixed_handler_panic() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy panic runtime installs an ambient context",
);
let panic_upstream = spawn_modern_as_proxy_panic_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(panic_upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-panic-gateway".to_owned(),
"e2e-ws-as-proxy-panic-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket panic gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-panic-upstream",
"native-h1:e2e-ws-as-proxy-panic-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-panic".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP panic proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP panic proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-panic-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed panic install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy panic must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket as_proxy panic publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy panic serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy panic handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket panic must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy panic initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-panic", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy panic over bind_websocket");
let prefixed = format!("ext/{PUBLIC_HTTP_PANIC_TOOL_NAME}");
let prefixed_fast = format!("ext/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy panic unprefixed",
client.call_tool(&cx, PUBLIC_HTTP_PANIC_TOOL_NAME, json!({})),
)
.await
.expect_err("changing only the tool name must not reach the unprefixed panic tool");
let _ = missing;
let panicked = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy panic tools/call",
client.call_tool(&cx, &prefixed, json!({})),
)
.await;
let panicked = match panicked {
Ok(result) if result.is_error => format!("{result:?}"),
Err(error) => format!("{error:?}"),
Ok(result) => {
panic!("a panicking as_proxy WebSocket tools/call must stay a protocol error: {result:?}")
}
};
assert!(
panicked.contains("Internal server error") || panicked.contains("InternalError"),
"as_proxy WebSocket must sanitize an upstream handler panic: {panicked}"
);
assert!(
!panicked.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"as_proxy WebSocket must not leak the upstream unwind payload: {panicked}"
);
let peer = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy panic fast peer",
client.call_tool(&cx, &prefixed_fast, json!({})),
)
.await
.expect("changing only the tool must still be admitted after a sanitized as_proxy panic");
assert!(
peer.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"the prefixed fast peer tool must still complete after a sanitized as_proxy panic: {peer:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
panic_upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_sanitizes_prefixed_catalog_and_completion_panic() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy catalog-panic runtime installs an ambient context",
);
let panic_upstream = spawn_modern_as_proxy_panic_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(panic_upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-catalog-panic-gateway".to_owned(),
"e2e-ws-as-proxy-catalog-panic-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket catalog-panic gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-catalog-panic-upstream",
"native-h1:e2e-ws-as-proxy-catalog-panic-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-catalog-panic".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP catalog-panic proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP catalog-panic proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-catalog-panic-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed catalog-panic install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy catalog-panic must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy catalog-panic publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy catalog-panic serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog-panic handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket catalog-panic must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog-panic initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-catalog-panic", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy catalog-panic over bind_websocket",
);
let prefixed_prompt = format!("ext/{PUBLIC_HTTP_PANIC_PROMPT_NAME}");
let prefixed_peer_prompt = format!("ext/{PUBLIC_HTTP_PROMPT_NAME}");
let prefixed_fast = format!("ext/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let missing_resource = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog-panic prefixed resource",
client.read_resource(&cx, &format!("ext/{PUBLIC_HTTP_PANIC_RESOURCE_URI}")),
)
.await
.expect_err("modern as_proxy must not invent a prefixed panic resource URI");
let _ = missing_resource;
let panicked_resource = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog-panic resources/read",
client.read_resource(&cx, PUBLIC_HTTP_PANIC_RESOURCE_URI),
)
.await;
let panicked_resource = match panicked_resource {
Ok(result) => panic!(
"a panicking as_proxy WebSocket resources/read must stay a protocol error: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
panicked_resource.contains("Internal server error")
|| panicked_resource.contains("InternalError"),
"as_proxy WebSocket must sanitize an upstream resource panic: {panicked_resource}"
);
assert!(
!panicked_resource.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"as_proxy WebSocket must not leak the upstream resource unwind payload: {panicked_resource}"
);
let missing_prompt = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog-panic unprefixed prompt",
client.get_prompt(&cx, PUBLIC_HTTP_PANIC_PROMPT_NAME, HashMap::new()),
)
.await
.expect_err("changing only the prompt name must not reach the unprefixed panic prompt");
let _ = missing_prompt;
let panicked_prompt = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog-panic prompts/get",
client.get_prompt(&cx, &prefixed_prompt, HashMap::new()),
)
.await;
let panicked_prompt = match panicked_prompt {
Ok(result) => panic!(
"a panicking as_proxy WebSocket prompts/get must stay a protocol error: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
panicked_prompt.contains("Internal server error")
|| panicked_prompt.contains("InternalError"),
"as_proxy WebSocket must sanitize an upstream prompt panic: {panicked_prompt}"
);
assert!(
!panicked_prompt.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"as_proxy WebSocket must not leak the upstream prompt unwind payload: {panicked_prompt}"
);
let missing_complete = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy completion-panic unprefixed",
client.complete(
&cx,
modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: PUBLIC_HTTP_PROMPT_NAME.to_owned(),
title: "Public HTTP E2E Prompt".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
context: None,
},
),
)
.await
.expect_err(
"changing only the completion prompt name must not reach the unprefixed provider",
);
let _ = missing_complete;
let panicked_complete = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy completion-panic complete",
client.complete(
&cx,
modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: prefixed_peer_prompt.clone(),
title: "Public HTTP E2E Prompt".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
context: None,
},
),
)
.await;
let panicked_complete = match panicked_complete {
Ok(result) => panic!(
"a panicking as_proxy WebSocket completion/complete must stay a protocol error: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
panicked_complete.contains("Internal server error")
|| panicked_complete.contains("InternalError"),
"as_proxy WebSocket must sanitize an upstream completion panic: {panicked_complete}"
);
assert!(
!panicked_complete.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"as_proxy WebSocket must not leak the upstream completion unwind payload: {panicked_complete}"
);
let peer_resource = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog-panic peer resource",
client.read_resource(&cx, PUBLIC_HTTP_RESOURCE_URI),
)
.await
.expect("changing only the resource must still be admitted after a sanitized as_proxy panic");
let peer_resource = format!("{peer_resource:?}");
assert!(
peer_resource.contains(PUBLIC_HTTP_RESOURCE_TEXT),
"the unprefixed peer resource must still complete after a sanitized as_proxy panic: {peer_resource}"
);
let peer_prompt = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog-panic peer prompt",
client.get_prompt(
&cx,
&prefixed_peer_prompt,
HashMap::from([("subject".to_owned(), PUBLIC_HTTP_TOOL_ARGUMENT.to_owned())]),
),
)
.await
.expect("changing only the prompt must still be admitted after a sanitized as_proxy panic");
let peer_prompt = format!("{peer_prompt:?}");
assert!(
peer_prompt.contains(PUBLIC_HTTP_PROMPT_TEXT),
"the prefixed peer prompt must still complete after a sanitized as_proxy panic: {peer_prompt}"
);
let peer = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy catalog-panic fast peer",
client.call_tool(&cx, &prefixed_fast, json!({})),
)
.await
.expect("changing only the method must still admit tools/call after a sanitized as_proxy panic");
assert!(
peer.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"the prefixed fast peer tool must still complete after a sanitized as_proxy catalog panic: {peer:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
panic_upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_gateway_cache_hits_prefixed_allowlisted_tool() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy cache runtime installs an ambient context",
);
let upstream = spawn_modern_as_proxy_counting_upstream();
let prefixed = format!("ext/{PUBLIC_HTTP_TOOL_NAME}");
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-cache-gateway".to_owned(),
"e2e-ws-as-proxy-cache-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket cache gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-cache-upstream",
"native-h1:e2e-ws-as-proxy-cache-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-cache".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP counting proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-cache-gateway", "1.0.0")
.middleware(
caching::ResponseCachingMiddleware::new().include_tools(vec![prefixed.clone()]),
)
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed cache install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy cache must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket as_proxy cache publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy cache serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy cache handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket cache must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy cache initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-cache", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy cache over bind_websocket");
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy cache unprefixed",
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect_err("changing only the tool name must not reach the unprefixed counting tool");
let _ = missing;
let first = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy cache miss",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("the first allowlisted prefixed tools/call must miss and reach the upstream");
assert!(
first.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the first as_proxy WebSocket cache miss must return the upstream handler result: {first:?}"
);
assert_eq!(
upstream.handler_call_snapshot().tool,
1,
"the first allowlisted prefixed tools/call must invoke the upstream counting handler"
);
let cached = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy cache hit",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("the second identical prefixed tools/call must be a gateway cache hit");
assert!(
cached.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the as_proxy WebSocket cache hit must keep the first complete result: {cached:?}"
);
assert_eq!(
upstream.handler_call_snapshot().tool,
1,
"a gateway cache hit must not invoke the upstream counting handler again"
);
let missed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy cache arg miss",
client.call_tool(&cx, &prefixed, json!({"value": "beta"})),
)
.await
.expect("changing only the arguments must miss the gateway cache");
assert!(
missed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:beta",
_ => false,
}),
"the as_proxy WebSocket cache miss must reach the upstream with the new arguments: {missed:?}"
);
assert_eq!(
upstream.handler_call_snapshot().tool,
2,
"a different-arguments prefixed tools/call must invoke the upstream counting handler"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_gateway_rate_limit_refuses_second_prefixed_call() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy rate-limit runtime installs an ambient context",
);
let upstream = spawn_modern_as_proxy_counting_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-rate-limit-gateway".to_owned(),
"e2e-ws-as-proxy-rate-limit-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket rate-limit gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-rate-limit-upstream",
"native-h1:e2e-ws-as-proxy-rate-limit-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-rate-limit".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP counting proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-rate-limit-gateway", "1.0.0")
.middleware(
rate_limiting::RateLimitingMiddleware::new(1.0e-300).burst_capacity(1),
)
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed rate-limit install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy rate-limit must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy rate-limit publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy rate-limit serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy rate-limit handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket rate-limit must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy rate-limit initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-rate-limit", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy rate-limit over bind_websocket",
);
let prefixed = format!("ext/{PUBLIC_HTTP_TOOL_NAME}");
let first = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy rate-limit first",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("the first prefixed tools/call must be admitted by the gateway rate limiter");
assert!(
first.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the first as_proxy WebSocket rate-limited tools/call must reach the upstream: {first:?}"
);
let limited = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy rate-limit second",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect_err("a second prefixed tools/call must be refused by the gateway rate limiter");
let limited = format!("{limited:?}");
assert!(
limited.contains("Rate limit exceeded"),
"the refused second as_proxy WebSocket tools/call must keep the rate-limit error: {limited}"
);
let listed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy rate-limit tools/list",
client.list_tools(&cx, None),
)
.await
.expect("changing only the method must still be admitted by the gateway rate limiter");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed),
"tools/list must stay callable after prefixed tools/call is rate-limited: {listed:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_gateway_sliding_window_refuses_second_prefixed_call() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy sliding-window runtime installs an ambient context",
);
let upstream = spawn_modern_as_proxy_counting_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-sliding-gateway".to_owned(),
"e2e-ws-as-proxy-sliding-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket sliding-window gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-sliding-upstream",
"native-h1:e2e-ws-as-proxy-sliding-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-sliding".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP counting proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-sliding-gateway", "1.0.0")
.middleware(rate_limiting::SlidingWindowRateLimitingMiddleware::new(
1, 60,
))
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed sliding-window install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy sliding-window must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy sliding-window publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy sliding-window serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy sliding handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket sliding-window must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy sliding initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-sliding", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy sliding-window over bind_websocket",
);
let prefixed = format!("ext/{PUBLIC_HTTP_TOOL_NAME}");
let first = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy sliding first",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("the first prefixed tools/call must be admitted by the gateway sliding window");
assert!(
first.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the first as_proxy WebSocket sliding-window tools/call must reach the upstream: {first:?}"
);
let limited = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy sliding second",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect_err("a second prefixed tools/call must be refused by the gateway sliding window");
let limited = format!("{limited:?}");
assert!(
limited.contains("Rate limit exceeded"),
"the refused second as_proxy WebSocket tools/call must keep the sliding-window error: {limited}"
);
let listed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy sliding tools/list",
client.list_tools(&cx, None),
)
.await
.expect("changing only the method must still be admitted by the gateway sliding window");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed),
"tools/list must stay callable after prefixed tools/call is sliding-window limited: {listed:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_gateway_static_token_refuses_missing_and_wrong() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy auth runtime installs an ambient context",
);
let upstream = spawn_modern_as_proxy_counting_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-auth-gateway".to_owned(),
"e2e-ws-as-proxy-auth-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket auth gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-auth-upstream",
"native-h1:e2e-ws-as-proxy-auth-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-auth".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP counting proxy catalog is typed");
let verifier = StaticTokenVerifier::new([(
"alpha",
AuthContext::with_subject(PUBLIC_HTTP_AUTH_SUBJECT),
)])
.expect("the deterministic as_proxy WebSocket bearer verifier is valid")
.with_allowed_schemes(["Bearer"])
.expect("the as_proxy WebSocket bearer scheme allowlist is valid");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-auth-gateway", "1.0.0")
.auth_provider(TokenAuthProvider::new(verifier))
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed auth install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy auth must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket as_proxy auth publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy auth serve must be admitted");
let (missing, _missing_stream) = websocket_upgrade_status(&cx, address, None).await;
assert!(
missing.starts_with(b"HTTP/1.1 401"),
"omitting Authorization must refuse the as_proxy WebSocket upgrade: {}",
String::from_utf8_lossy(&missing)
);
assert!(
String::from_utf8_lossy(&missing)
.to_ascii_lowercase()
.contains("www-authenticate"),
"the missing-token as_proxy WebSocket upgrade must challenge Bearer: {}",
String::from_utf8_lossy(&missing)
);
assert_eq!(
upstream.handler_call_snapshot().tool,
0,
"missing Authorization must not reach the as_proxy upstream"
);
let (wrong, _wrong_stream) =
websocket_upgrade_status(&cx, address, Some("Bearer beta")).await;
assert!(
wrong.starts_with(b"HTTP/1.1 401"),
"a wrong bearer token must refuse the as_proxy WebSocket upgrade: {}",
String::from_utf8_lossy(&wrong)
);
assert_eq!(
upstream.handler_call_snapshot().tool,
0,
"a wrong bearer token must not reach the as_proxy upstream"
);
let (accepted, stream) =
websocket_upgrade_status(&cx, address, Some("Bearer alpha")).await;
assert!(
accepted.starts_with(b"HTTP/1.1 101"),
"the matching bearer token must complete RFC 6455 upgrade: {}",
String::from_utf8_lossy(&accepted)
);
let transport = AsyncWsClientTransport::from_upgraded(stream);
let mut client = websocket_client_bounded(
&cx,
"live as_proxy WebSocket auth initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-auth", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("an admitted as_proxy WebSocket upgrade must negotiate ModernOnly");
let prefixed = format!("ext/{PUBLIC_HTTP_TOOL_NAME}");
let admitted = websocket_client_bounded(
&cx,
"live as_proxy WebSocket auth tools/call",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("the matching bearer token must reach the prefixed as_proxy upstream");
assert!(
admitted.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the matching as_proxy WebSocket bearer token must reach the upstream: {admitted:?}"
);
assert_eq!(
upstream.handler_call_snapshot().tool,
1,
"only the matching bearer token may invoke the as_proxy upstream"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_gateway_session_state_is_request_local_across_calls() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy session-state runtime installs an ambient context",
);
let upstream = spawn_modern_as_proxy_state_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-state-gateway".to_owned(),
"e2e-ws-as-proxy-state-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket session-state gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-state-upstream",
"native-h1:e2e-ws-as-proxy-state-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-state".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP state proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP state proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-state-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed session-state install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy session-state must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy session-state publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy session-state serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy session-state handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket session-state must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy session-state initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-state", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy session-state over bind_websocket",
);
let prefixed = format!("ext/{PUBLIC_HTTP_STATE_TOOL_NAME}");
let written = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy session-state write",
client.call_tool(
&cx,
&prefixed,
json!({"action": "write", "value": "alpha"}),
),
)
.await
.expect("as_proxy WebSocket must write request-local session state on the upstream");
assert!(
written.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "state:alpha",
_ => false,
}),
"set_state then get_state on the same as_proxy WebSocket call must retain: {written:?}"
);
let later_read = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy session-state later read",
client.call_tool(&cx, &prefixed, json!({"action": "read"})),
)
.await
.expect("a later as_proxy WebSocket tools/call must still be admitted");
assert!(
later_read.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "state:missing",
_ => false,
}),
"changing only the call must not reuse the previous as_proxy upstream bag: {later_read:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_gateway_strict_input_refuses_unknown_property() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy strict runtime installs an ambient context",
);
let upstream = spawn_modern_as_proxy_counting_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-strict-gateway".to_owned(),
"e2e-ws-as-proxy-strict-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket strict gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-strict-upstream",
"native-h1:e2e-ws-as-proxy-strict-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-strict".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP counting proxy catalog is typed");
let strict_server =
modern::ServerBuilder::new("e2e-ws-as-proxy-strict-gateway", "1.0.0")
.strict_input_validation(true)
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed strict install must succeed")
.build();
let mut registry = ProxyClient::upstream_binding_registry();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-lenient-gateway".to_owned(),
"e2e-ws-as-proxy-lenient-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket lenient gateway HTTP plan is valid");
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-lenient-upstream",
"native-h1:e2e-ws-as-proxy-lenient-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-lenient".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP counting proxy catalog is typed");
let lenient_server =
modern::ServerBuilder::new("e2e-ws-as-proxy-lenient-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed lenient install must succeed")
.build();
let strict_bound = strict_server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy strict must bind");
let strict_address = strict_bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy strict publishes its address",
);
let lenient_bound = lenient_server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy lenient must bind");
let lenient_address = lenient_bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy lenient publishes its address",
);
let scope = cx.scope();
let strict_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
strict_bound.serve(&serve_cx).await
})
.expect("public ModernOnly bind_websocket as_proxy strict serve must be admitted");
let lenient_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
lenient_bound.serve(&serve_cx).await
})
.expect("public ModernOnly bind_websocket as_proxy lenient serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy strict handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{strict_address}/mcp")),
)
.await
.expect("as_proxy WebSocket strict must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy strict initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-strict", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy strict over bind_websocket");
let prefixed = format!("ext/{PUBLIC_HTTP_TOOL_NAME}");
let admitted = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy strict declared",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("strict as_proxy must still admit declared arguments");
assert!(
admitted.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"declared arguments must reach the prefixed upstream under gateway strict validation: {admitted:?}"
);
let refused = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy strict extra-property",
client.call_tool(&cx, &prefixed, json!({"value": "alpha", "extra": 1})),
)
.await
.expect("strict as_proxy returns a complete tools/call result, not a transport failure");
assert!(
refused.is_error,
"an unknown property must become a tool-level error when gateway strict validation is on: {refused:?}"
);
assert!(
refused.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text.contains("do not match the declared input schema")
|| text.contains("additional property")
}
_ => false,
}),
"the as_proxy WebSocket strict refusal must name the input-schema mismatch: {refused:?}"
);
drop(client);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy lenient handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{lenient_address}/mcp")),
)
.await
.expect("as_proxy WebSocket lenient must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy lenient initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-lenient", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy lenient over bind_websocket");
let extra = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy lenient extra-property",
client.call_tool(&cx, &prefixed, json!({"value": "alpha", "extra": 1})),
)
.await
.expect("lenient as_proxy must admit the same extra property");
assert!(
extra.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"changing only the gateway strict flag must admit the extra property: {extra:?}"
);
drop(client);
cx.set_cancel_requested(true);
strict_listener.abort();
lenient_listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_gateway_list_page_size_pages_prefixed_tools() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy list_page_size runtime installs an ambient context",
);
let upstream = spawn_modern_as_proxy_counting_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-page-gateway".to_owned(),
"e2e-ws-as-proxy-page-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket list_page_size gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-page-upstream",
"native-h1:e2e-ws-as-proxy-page-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-page".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP counting proxy catalog is typed");
let paged_server =
modern::ServerBuilder::new("e2e-ws-as-proxy-page-gateway", "1.0.0")
.list_page_size(1)
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed list_page_size install must succeed")
.build();
let mut registry = ProxyClient::upstream_binding_registry();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-unpaged-gateway".to_owned(),
"e2e-ws-as-proxy-unpaged-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket default-list gateway HTTP plan is valid");
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-unpaged-upstream",
"native-h1:e2e-ws-as-proxy-unpaged-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-unpaged".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP counting proxy catalog is typed");
let unpaged_server =
modern::ServerBuilder::new("e2e-ws-as-proxy-unpaged-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed default-list install must succeed")
.build();
let paged_bound = paged_server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy list_page_size must bind");
let paged_address = paged_bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy list_page_size publishes its address",
);
let unpaged_bound = unpaged_server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy default list must bind");
let unpaged_address = unpaged_bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy default list publishes its address",
);
let scope = cx.scope();
let paged_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
paged_bound.serve(&serve_cx).await
})
.expect(
"public ModernOnly bind_websocket as_proxy list_page_size serve must be admitted",
);
let unpaged_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
unpaged_bound.serve(&serve_cx).await
})
.expect(
"public ModernOnly bind_websocket as_proxy default list serve must be admitted",
);
let prefixed = format!("ext/{PUBLIC_HTTP_TOOL_NAME}");
let prefixed_fast = format!("ext/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy list_page_size handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{paged_address}/mcp")),
)
.await
.expect("as_proxy WebSocket list_page_size must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy list_page_size initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-page", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy list_page_size over bind_websocket",
);
let first = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy list_page_size first",
client.list_tools(&cx, None),
)
.await
.expect("gateway list_page_size(1) must page prefixed tools");
assert_eq!(
first.tools.len(),
1,
"as_proxy gateway list_page_size(1) must create a real continuation: {first:?}"
);
let first_name = first.tools[0].name.clone();
assert!(
first_name == prefixed || first_name == prefixed_fast,
"the first as_proxy page must retain a prefixed catalog tool: {first:?}"
);
let cursor = first
.next_cursor
.clone()
.expect("the first as_proxy page must carry an opaque cursor");
let second = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy list_page_size continuation",
client.list_tools(&cx, Some(cursor.as_str())),
)
.await
.expect("the as_proxy cursor continuation must reach the second page");
assert_eq!(
second.tools.len(),
1,
"the as_proxy continuation must retain one remaining prefixed tool: {second:?}"
);
assert_ne!(
second.tools[0].name, first_name,
"the continuation must retain the other prefixed tool: {second:?}"
);
drop(client);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy default-list handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{unpaged_address}/mcp")),
)
.await
.expect("as_proxy WebSocket default list must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy default-list initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-unpaged", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy default list over bind_websocket",
);
let listed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy default list",
client.list_tools(&cx, None),
)
.await
.expect("the default as_proxy catalog must still list prefixed tools");
assert!(
listed.tools.len() >= 2,
"changing only the missing list_page_size must not invent a 1-item page: {listed:?}"
);
drop(client);
cx.set_cancel_requested(true);
paged_listener.abort();
unpaged_listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_gateway_on_duplicate_error_keeps_and_replace_installs() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy on_duplicate runtime installs an ambient context",
);
let upstream = spawn_modern_as_proxy_counting_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-duplicate-error-gateway".to_owned(),
"e2e-ws-as-proxy-duplicate-error-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket on_duplicate Error gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-duplicate-error-upstream",
"native-h1:e2e-ws-as-proxy-duplicate-error-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-duplicate-error".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP counting proxy catalog is typed");
let error_server =
modern::ServerBuilder::new("e2e-ws-as-proxy-duplicate-error", "1.0.0")
.on_duplicate(DuplicateBehavior::Error)
.tool(LocalPrefixedPublicHttpValue {
name: format!("ext/{PUBLIC_HTTP_TOOL_NAME}"),
})
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed on_duplicate Error install must succeed")
.build();
let bound = error_server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy on_duplicate Error must bind");
let error_address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy on_duplicate Error publishes its address",
);
let scope = cx.scope();
let error_listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy on_duplicate Error serve must be admitted",
);
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-duplicate-replace-gateway".to_owned(),
"e2e-ws-as-proxy-duplicate-replace-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket on_duplicate Replace gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-duplicate-replace-upstream",
"native-h1:e2e-ws-as-proxy-duplicate-replace-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-duplicate-replace".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP counting proxy upstream must connect for the Replace gateway",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP counting proxy catalog is typed for Replace");
let replace_server =
modern::ServerBuilder::new("e2e-ws-as-proxy-duplicate-replace", "1.0.0")
.on_duplicate(DuplicateBehavior::Replace)
.tool(LocalPrefixedPublicHttpValue {
name: format!("ext/{PUBLIC_HTTP_TOOL_NAME}"),
})
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed on_duplicate Replace install must succeed")
.build();
let bound = replace_server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy on_duplicate Replace must bind");
let replace_address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy on_duplicate Replace publishes its address",
);
let replace_listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy on_duplicate Replace serve must be admitted",
);
let prefixed = format!("ext/{PUBLIC_HTTP_TOOL_NAME}");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy on_duplicate Error handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{error_address}/mcp")),
)
.await
.expect("as_proxy WebSocket on_duplicate Error must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy on_duplicate Error initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-duplicate-error", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy on_duplicate Error over bind_websocket",
);
let kept = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy on_duplicate Error call",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("Error must keep the local prefixed tool callable");
assert!(
kept.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "local:alpha",
_ => false,
}),
"as_proxy on_duplicate Error must keep the local prefixed handler: {kept:?}"
);
assert_eq!(
upstream.handler_calls.tool.load(Ordering::SeqCst),
0,
"Error must not forward the colliding prefixed name to the upstream"
);
drop(client);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy on_duplicate Replace handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{replace_address}/mcp")),
)
.await
.expect("as_proxy WebSocket on_duplicate Replace must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy on_duplicate Replace initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-duplicate-replace", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy on_duplicate Replace over bind_websocket",
);
let replaced = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy on_duplicate Replace call",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("Replace must install the proxied catalog entry");
assert!(
replaced.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"changing only on_duplicate to Replace must reach the prefixed upstream: {replaced:?}"
);
assert_eq!(
upstream.handler_calls.tool.load(Ordering::SeqCst),
1,
"Replace must forward the colliding prefixed name to the upstream"
);
drop(client);
cx.set_cancel_requested(true);
error_listener.abort();
replace_listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_forwards_transformed_prefixed_tool() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy transform runtime installs an ambient context",
);
let upstream = spawn_modern_as_proxy_transform_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-transform-gateway".to_owned(),
"e2e-ws-as-proxy-transform-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket transform gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-transform-upstream",
"native-h1:e2e-ws-as-proxy-transform-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-transform".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP transform proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP transform proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-transform-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed transform install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy transform must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy transform publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy transform serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy transform handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket transform must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy transform initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-transform", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy transform over bind_websocket",
);
let prefixed = format!("ext/{PUBLIC_HTTP_TRANSFORMED_TOOL_NAME}");
let listed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy transform tools/list",
client.list_tools(&cx, None),
)
.await
.expect("as_proxy WebSocket must list the prefixed transformed tool");
let transformed = listed
.tools
.iter()
.find(|tool| tool.name == prefixed)
.unwrap_or_else(|| {
panic!(
"the as_proxy WebSocket catalog must advertise the prefixed transformed name: {listed:?}"
)
});
assert!(
listed.tools.iter().all(|tool| tool.name != PUBLIC_HTTP_TRANSFORMED_TOOL_NAME
&& tool.name != PUBLIC_HTTP_TOOL_NAME
&& tool.name != format!("ext/{PUBLIC_HTTP_TOOL_NAME}")),
"as_proxy WebSocket must not keep the unprefixed transformed name or the parent tool: {listed:?}"
);
let schema = serde_json::to_string(&transformed.input_schema)
.expect("the prefixed transformed input schema serializes");
assert!(
schema.contains("query"),
"as_proxy WebSocket must retain the renamed argument on the prefixed catalog: {schema}"
);
assert!(
!schema.contains("\"value\""),
"as_proxy WebSocket must drop the original argument name from the prefixed catalog: {schema}"
);
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy transform unprefixed",
client.call_tool(
&cx,
PUBLIC_HTTP_TRANSFORMED_TOOL_NAME,
json!({"query": "alpha"}),
),
)
.await
.expect_err("changing only the tool name must not reach the unprefixed transformed tool");
let _ = missing;
let looked_up = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy transform tools/call",
client.call_tool(&cx, &prefixed, json!({"query": "alpha"})),
)
.await
.expect("as_proxy WebSocket must forward the renamed argument onto the live transformed upstream");
assert!(
looked_up.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the rewritten argument must reach the parent handler through as_proxy WebSocket: {looked_up:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_forwards_inbound_log_level() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy log runtime installs an ambient context",
);
let upstream = spawn_modern_log_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-log-gateway".to_owned(),
"e2e-ws-as-proxy-log-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket log gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-log-upstream",
"native-h1:e2e-ws-as-proxy-log-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-log".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP log proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP log proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-log-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed log install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy log must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket as_proxy log publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy log serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy log handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket log must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy log initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-log", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy log over bind_websocket");
let prefixed = format!("ext/{PUBLIC_HTTP_LOG_TOOL_NAME}");
let listed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy log list_tools",
client.list_tools(&cx, None),
)
.await
.expect("the live as_proxy WebSocket gateway must advertise the prefixed log tool");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed),
"as_proxy_typed must prefix the live log tool over WebSocket: {listed:?}"
);
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy log unprefixed",
client.call_tool(&cx, PUBLIC_HTTP_LOG_TOOL_NAME, json!({})),
)
.await
.expect_err("changing only the tool name must not reach the unprefixed log handler");
let _ = missing;
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy log tools/call before set_log_level",
client.call_tool(&cx, &prefixed, json!({})),
)
.await
.expect("as_proxy WebSocket must still forward the prefixed log tool before set_log_level");
assert!(
!client.take_server_notifications().iter().any(|notification| {
matches!(
notification,
modern::ServerNotification::Message(message)
if message.data == json!(PUBLIC_HTTP_HANDLER_LOG_TEXT)
)
}),
"omitting only set_log_level must keep as_proxy WebSocket ctx.info silent"
);
client
.set_log_level(modern::LoggingLevel::Info)
.expect("info logLevel is stored as request metadata");
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy log tools/call after Info",
client.call_tool(&cx, &prefixed, json!({})),
)
.await
.expect("as_proxy WebSocket must forward the prefixed log tool after set_log_level(Info)");
let info_notifications = client.take_server_notifications();
assert!(
info_notifications.iter().any(|notification| matches!(
notification,
modern::ServerNotification::Message(message)
if message.level == modern::LoggingLevel::Info
&& message.data == json!(PUBLIC_HTTP_HANDLER_LOG_TEXT)
)),
"as_proxy WebSocket must retain upstream ctx.info after inbound set_log_level(Info): {info_notifications:?}"
);
client
.set_log_level(modern::LoggingLevel::Emergency)
.expect("emergency logLevel still stores request metadata locally");
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy log tools/call after Emergency",
client.call_tool(&cx, &prefixed, json!({})),
)
.await
.expect("raising only the logLevel floor cannot break the same as_proxy WebSocket tools/call");
let emergency_notifications = client.take_server_notifications();
assert!(
!emergency_notifications.iter().any(|notification| matches!(
notification,
modern::ServerNotification::Message(message)
if message.level == modern::LoggingLevel::Info
&& message.data == json!(PUBLIC_HTTP_HANDLER_LOG_TEXT)
)),
"raising only the inbound logLevel floor must suppress as_proxy WebSocket ctx.info: {emergency_notifications:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy log close",
client.close(&cx),
)
.await
.expect("the as_proxy WebSocket log client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_forwards_inbound_sampling_capability() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy sampling runtime installs an ambient context",
);
let upstream = spawn_modern_sampling_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-sampling-gateway".to_owned(),
"e2e-ws-as-proxy-sampling-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket sampling gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-sampling-upstream",
"native-h1:e2e-ws-as-proxy-sampling-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-sampling".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP sampling proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP sampling proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-sampling-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed sampling install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy sampling must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy sampling publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy sampling serve must be admitted");
let mut capabilities = ClientCapabilities::default();
capabilities.sampling = Some(Default::default());
capabilities.roots = serde_json::from_value(json!({})).expect("roots capability is valid");
capabilities.elicitation = serde_json::from_value(json!({"form": {}, "url": {}}))
.expect("form and url elicitation capabilities are valid");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy sampling handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket sampling must complete RFC 6455 upgrade");
let mut admitted = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy sampling initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-sampling", "1.0.0")
.capabilities(capabilities)
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the sampling ModernOnly public facade negotiates as_proxy over bind_websocket");
let prefixed_sample = format!("ext/{PUBLIC_HTTP_SAMPLING_TOOL_NAME}");
let prefixed_roots = format!("ext/{PUBLIC_HTTP_ROOTS_TOOL_NAME}");
let listed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy sampling list_tools",
admitted.list_tools(&cx, None),
)
.await
.expect("the live as_proxy WebSocket gateway must advertise the prefixed sampling tool");
assert!(
listed.tools.iter().any(|tool| tool.name == prefixed_sample),
"as_proxy_typed must prefix the live sampling tool over WebSocket: {listed:?}"
);
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy sampling unprefixed",
admitted.call_tool_result(&cx, PUBLIC_HTTP_SAMPLING_TOOL_NAME, json!({})),
)
.await
.expect_err("changing only the tool name must not reach the unprefixed sampling handler");
let _ = missing;
let sampled = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy sampling tools/call",
admitted.call_tool_result(&cx, &prefixed_sample, json!({})),
)
.await
.expect("as_proxy WebSocket must forward inbound sampling so upstream ctx.final_sampling is admitted");
let FinalCoreResult::ToolsCallInputRequired { result, .. } = sampled else {
panic!("as_proxy WebSocket must retain upstream sampling input_required: {sampled:?}");
};
assert_live_input_required(&result, "sample", "as_proxy WebSocket tools/call");
let rooted = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy roots tools/call",
admitted.call_tool_result(&cx, &prefixed_roots, json!({})),
)
.await
.expect("as_proxy WebSocket must forward inbound roots so upstream ctx.final_roots is admitted");
let FinalCoreResult::ToolsCallInputRequired { result, .. } = rooted else {
panic!("as_proxy WebSocket must retain upstream roots input_required: {rooted:?}");
};
assert_live_input_required(&result, "roots", "as_proxy WebSocket tools/call");
let prefixed_url = format!("ext/{PUBLIC_HTTP_URL_ELICITATION_TOOL_NAME}");
let elicited = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy URL elicitation tools/call",
admitted.call_tool_result(&cx, &prefixed_url, json!({})),
)
.await
.expect("as_proxy WebSocket must forward inbound elicitation.url so upstream ctx.final_elicitation_url is admitted");
let FinalCoreResult::ToolsCallInputRequired { result, .. } = elicited else {
panic!("as_proxy WebSocket must retain upstream URL elicitation input_required: {elicited:?}");
};
assert_live_input_required(&result, "approval", "as_proxy WebSocket tools/call");
let form_resource = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy form elicitation resources/read",
admitted.read_resource_result(&cx, PUBLIC_HTTP_ELICITATION_RESOURCE_URI),
)
.await
.expect("as_proxy WebSocket must forward inbound elicitation.form so upstream ctx.final_elicitation_form is admitted");
let FinalCoreResult::ResourcesReadInputRequired { result, .. } = form_resource else {
panic!("as_proxy WebSocket must retain upstream form elicitation input_required: {form_resource:?}");
};
assert_live_input_required(&result, "approval", "as_proxy WebSocket resources/read");
let prefixed_form_prompt = format!("ext/{PUBLIC_HTTP_ELICITATION_PROMPT_NAME}");
let form_prompt = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy form elicitation prompts/get",
admitted.get_prompt_result(&cx, &prefixed_form_prompt, HashMap::new()),
)
.await
.expect("as_proxy WebSocket must forward inbound elicitation.form on prefixed prompts/get");
let FinalCoreResult::PromptsGetInputRequired { result, .. } = form_prompt else {
panic!("as_proxy WebSocket must retain upstream prompt form elicitation input_required: {form_prompt:?}");
};
assert_live_input_required(&result, "approval", "as_proxy WebSocket prompts/get");
let _ = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy unprefixed form elicitation prompt",
admitted.get_prompt_result(&cx, PUBLIC_HTTP_ELICITATION_PROMPT_NAME, HashMap::new()),
)
.await
.expect_err("changing only the prompt name must not reach the unprefixed elicitation prompt");
let mut no_form_capabilities = ClientCapabilities::default();
no_form_capabilities.elicitation =
serde_json::from_value(json!({"url": {}})).expect("url elicitation capability is valid");
let no_form_transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy url-only handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("url-only as_proxy WebSocket must complete RFC 6455 upgrade");
let mut no_form = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy url-only initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-sampling-no-form", "1.0.0")
.capabilities(no_form_capabilities)
.connect_websocket_with_cx(&cx, no_form_transport),
)
.await
.expect("the url-only ModernOnly public facade negotiates as_proxy over bind_websocket");
let missing_form = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy url-only form resources/read",
no_form.read_resource_result(&cx, PUBLIC_HTTP_ELICITATION_RESOURCE_URI),
)
.await;
match missing_form {
Err(error) => assert!(
error.to_string().contains("not advertised by the client"),
"as_proxy WebSocket must not invent inbound elicitation.form: {error:?}"
),
Ok(result) => panic!(
"omitting only inbound elicitation.form must fail closed: {result:?}"
),
}
let mut bare_capabilities = ClientCapabilities::default();
bare_capabilities.elicitation = serde_json::from_value(json!({"form": {}}))
.expect("form elicitation capability is valid");
let bare_transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy sampling bare handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("bare as_proxy WebSocket sampling must complete RFC 6455 upgrade");
let mut bare = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy sampling bare initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-sampling-bare", "1.0.0")
.capabilities(bare_capabilities)
.connect_websocket_with_cx(&cx, bare_transport),
)
.await
.expect("the bare ModernOnly public facade negotiates as_proxy sampling over bind_websocket");
let refused = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy sampling bare tools/call",
bare.call_tool_result(&cx, &prefixed_sample, json!({})),
)
.await
.expect("omitting only inbound sampling must still complete WebSocket tools/call as a tool error");
let FinalCoreResult::ToolsCall { result, .. } = refused else {
panic!("omitting only inbound sampling must fail closed as a WebSocket tool error: {refused:?}");
};
assert!(
result.payload.is_error,
"as_proxy WebSocket must not invent inbound sampling for the upstream handler: {result:?}"
);
let missing_url = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy form-only URL elicitation",
bare.call_tool_result(&cx, &prefixed_url, json!({})),
)
.await
.expect("omitting only inbound elicitation.url must still complete WebSocket tools/call as a tool error");
let FinalCoreResult::ToolsCall { result, .. } = missing_url else {
panic!("omitting only inbound elicitation.url must fail closed as a WebSocket tool error: {missing_url:?}");
};
assert!(
result.payload.is_error,
"as_proxy WebSocket must not invent inbound elicitation.url for the upstream handler: {result:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy sampling close",
admitted.close(&cx),
)
.await
.expect("the admitted as_proxy WebSocket sampling client closes after the live proof");
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy url-only close",
no_form.close(&cx),
)
.await
.expect("the url-only as_proxy WebSocket sampling client closes after the live proof");
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy sampling bare close",
bare.close(&cx),
)
.await
.expect("the bare as_proxy WebSocket sampling client closes after the live proof");
drop(admitted);
drop(bare);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_stdio_follows_form_elicitation() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy stdio elicitation runtime installs an ambient context",
);
let stdio = connect_warmed_stdio_as_proxy_upstream(
&cx,
"e2e-ws-as-proxy-stdio-elicit-upstream",
);
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-stdio-elicit", "1.0.0")
.as_proxy("ext", stdio)
.expect("as_proxy stdio elicitation install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy stdio elicitation must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy stdio elicitation publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy stdio elicitation serve must be admitted");
let mut capabilities = ClientCapabilities::default();
capabilities.elicitation = serde_json::from_value(json!({"form": {}, "url": {}}))
.expect("form and url elicitation capabilities are valid");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio elicitation handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket stdio elicitation must complete RFC 6455 upgrade");
let mut admitted = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio elicitation initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-stdio-elicit", "1.0.0")
.capabilities(capabilities.clone())
.modern_reverse_request_handlers(public_modern_elicitation_follow_handlers())
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the elicitation-follow ModernOnly facade negotiates as_proxy over bind_websocket");
let resource = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio form resource follow",
admitted.read_resource(&cx, "info://elicit-form"),
)
.await
.expect("as_proxy WebSocket read_resource follows form elicitation through stdio echo");
assert!(
resource.contents.iter().any(|content| match content {
EmbeddedResourceContents::Text { text, .. } => text == "form-elicit:true",
_ => false,
}),
"as_proxy must resume stdio form elicitation on resources/read: {resource:?}"
);
let prompt = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio form prompt follow",
admitted.get_prompt(&cx, "ext/elicit_form_greeting", HashMap::new()),
)
.await
.expect("as_proxy WebSocket get_prompt follows form elicitation through stdio echo");
assert!(
prompt.messages.iter().any(|message| match &message.content {
ContentBlock::Text { text, .. } => text == "form-elicit:true",
_ => false,
}),
"as_proxy must resume stdio form elicitation on prompts/get: {prompt:?}"
);
let url = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio URL tool follow",
admitted.call_tool(&cx, "ext/url_elicit_echo", json!({})),
)
.await
.expect("as_proxy WebSocket call_tool follows URL elicitation through stdio echo");
assert!(
url.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "url-elicit:accept",
_ => false,
}),
"as_proxy must resume stdio URL elicitation on tools/call: {url:?}"
);
let result_transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio elicitation result handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("result-only as_proxy WebSocket elicitation must complete RFC 6455 upgrade");
let mut result_only = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio elicitation result initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-stdio-elicit-result", "1.0.0")
.capabilities(capabilities)
.connect_websocket_with_cx(&cx, result_transport),
)
.await
.expect("the result-only ModernOnly facade negotiates as_proxy over bind_websocket");
let pending = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio elicitation result resource",
result_only.read_resource_result(&cx, "info://elicit-form"),
)
.await
.expect("omitting only the elicitation handler must keep the input_required branch");
let FinalCoreResult::ResourcesReadInputRequired { result, .. } = pending else {
panic!(
"changing only the installed elicitation handler must keep form input_required: {pending:?}"
);
};
assert_live_input_required(&result, "approval", "as_proxy WebSocket stdio resources/read");
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio elicitation close",
admitted.close(&cx),
)
.await
.expect("the elicitation-follow as_proxy WebSocket client closes after the live proof");
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio elicitation result close",
result_only.close(&cx),
)
.await
.expect("the result-only as_proxy WebSocket client closes after the live proof");
drop(admitted);
drop(result_only);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[cfg(all(unix, feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_stdio_follows_sampling_and_roots() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy stdio sampling runtime installs an ambient context",
);
let stdio = connect_warmed_stdio_as_proxy_upstream(
&cx,
"e2e-ws-as-proxy-stdio-sample-upstream",
);
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-stdio-sample", "1.0.0")
.as_proxy("ext", stdio)
.expect("as_proxy stdio sampling install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy stdio sampling must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket as_proxy stdio sampling publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy stdio sampling serve must be admitted");
let mut capabilities = ClientCapabilities::default();
capabilities.sampling = Some(Default::default());
capabilities.roots = serde_json::from_value(json!({})).expect("roots capability is valid");
let handlers = modern::ReverseRequestHandlers::new()
.with_modern_sampling_create_message(|_cx, _cancellation, _params| {
Box::pin(async {
Ok(modern::FinalCreateMessageResult {
content: modern::FinalSamplingMessageContent::Block(
modern::FinalSamplingMessageContentBlock::Text {
text: "sampled".to_owned(),
annotations: None,
meta: None,
additional: std::collections::BTreeMap::new(),
},
),
model: "as-proxy-ws-sample-model".to_owned(),
role: Role::Assistant,
stop_reason: None,
meta: None,
})
})
})
.with_modern_roots_list(|_cx, _cancellation, _params| {
Box::pin(async { Ok(modern::FinalEmbeddedRootsListResult { roots: vec![] }) })
});
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio sampling handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket stdio sampling must complete RFC 6455 upgrade");
let mut admitted = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio sampling initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-stdio-sample", "1.0.0")
.capabilities(capabilities.clone())
.modern_reverse_request_handlers(handlers)
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the sampling-follow ModernOnly facade negotiates as_proxy over bind_websocket");
let sampled = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio sampling follow",
admitted.call_tool(&cx, "ext/sample_echo", json!({})),
)
.await
.expect("as_proxy WebSocket call_tool follows sampling through stdio echo");
assert!(
sampled.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "sampled:as-proxy-ws-sample-model",
_ => false,
}),
"as_proxy must resume stdio sampling on tools/call: {sampled:?}"
);
let roots = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio roots follow",
admitted.call_tool(&cx, "ext/roots_echo", json!({})),
)
.await
.expect("as_proxy WebSocket call_tool follows roots through stdio echo");
assert!(
roots.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "roots:0",
_ => false,
}),
"as_proxy must resume stdio roots on tools/call: {roots:?}"
);
let result_transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio sampling result handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("result-only as_proxy WebSocket sampling must complete RFC 6455 upgrade");
let mut result_only = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio sampling result initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-stdio-sample-result", "1.0.0")
.capabilities(capabilities)
.connect_websocket_with_cx(&cx, result_transport),
)
.await
.expect("the result-only ModernOnly facade negotiates as_proxy over bind_websocket");
let pending = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio sampling result tool",
result_only.call_tool_result(&cx, "ext/sample_echo", json!({})),
)
.await
.expect("omitting only the sampling handler must keep the input_required branch");
let FinalCoreResult::ToolsCallInputRequired { result, .. } = pending else {
panic!(
"changing only the installed sampling handler must keep input_required: {pending:?}"
);
};
assert_live_input_required(&result, "sample", "as_proxy WebSocket stdio tools/call");
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio sampling close",
admitted.close(&cx),
)
.await
.expect("the sampling-follow as_proxy WebSocket client closes after the live proof");
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy stdio sampling result close",
result_only.close(&cx),
)
.await
.expect("the result-only as_proxy WebSocket client closes after the live proof");
drop(admitted);
drop(result_only);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_forwards_inbound_progress_marker() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy progress runtime installs an ambient context",
);
let upstream = spawn_modern_progress_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-progress-gateway".to_owned(),
"e2e-ws-as-proxy-progress-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket progress gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-progress-upstream",
"native-h1:e2e-ws-as-proxy-progress-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-progress".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP progress proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP progress proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-progress-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed progress install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy progress must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy progress publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy progress serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy progress handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket progress must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy progress initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-progress", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy progress over bind_websocket");
let prefixed_tool = format!("ext/{PUBLIC_HTTP_PROGRESS_TOOL_NAME}");
let prefixed_prompt = format!("ext/{PUBLIC_HTTP_PROGRESS_PROMPT_NAME}");
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy progress tools/call silent",
client.call_tool(&cx, &prefixed_tool, json!({})),
)
.await
.expect("as_proxy WebSocket tools/call still completes without a progress token");
assert!(
client.take_progress_notifications().is_empty(),
"as_proxy WebSocket must not invent request-scoped progress without a progressToken"
);
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy progress unprefixed",
client.call_tool_with_progress_marker(
&cx,
PUBLIC_HTTP_PROGRESS_TOOL_NAME,
json!({}),
modern::ProgressMarker::from("as-proxy-ws-progress-unprefixed"),
),
)
.await
.expect_err("changing only the tool name must not reach the unprefixed progress handler");
let _ = missing;
let marker = modern::ProgressMarker::from("as-proxy-ws-progress");
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy progress tools/call",
client.call_tool_with_progress_marker(&cx, &prefixed_tool, json!({}), marker.clone()),
)
.await
.expect("as_proxy WebSocket must forward an inbound progressToken onto the upstream tools/call");
let progress = client.take_progress_notifications();
assert!(
progress.iter().any(|notification| {
notification.progress_token == marker
&& notification.message.as_deref() == Some("halfway")
}),
"as_proxy WebSocket must retain upstream tools/call notifications/progress: {progress:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy progress resources/read silent",
client.read_resource(&cx, PUBLIC_HTTP_PROGRESS_RESOURCE_URI),
)
.await
.expect("as_proxy WebSocket resources/read still completes without a progress token");
assert!(
client.take_progress_notifications().is_empty(),
"as_proxy WebSocket must not invent resource progress without a progressToken"
);
let resource_marker = modern::ProgressMarker::from("as-proxy-ws-resource-progress");
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy progress resources/read",
client.read_resource_with_progress_marker(
&cx,
PUBLIC_HTTP_PROGRESS_RESOURCE_URI,
resource_marker.clone(),
),
)
.await
.expect("as_proxy WebSocket must forward an inbound progressToken onto the unprefixed resource URI");
let resource_progress = client.take_progress_notifications();
assert!(
resource_progress.iter().any(|notification| {
notification.progress_token == resource_marker
&& notification.message.as_deref() == Some("resource-halfway")
}),
"as_proxy WebSocket must retain upstream resources/read notifications/progress: {resource_progress:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy progress prompts/get silent",
client.get_prompt(&cx, &prefixed_prompt, HashMap::new()),
)
.await
.expect("as_proxy WebSocket prompts/get still completes without a progress token");
assert!(
client.take_progress_notifications().is_empty(),
"as_proxy WebSocket must not invent prompt progress without a progressToken"
);
let prompt_marker = modern::ProgressMarker::from("as-proxy-ws-prompt-progress");
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy progress prompts/get",
client.get_prompt_with_progress_marker(
&cx,
&prefixed_prompt,
HashMap::new(),
prompt_marker.clone(),
),
)
.await
.expect("as_proxy WebSocket must forward an inbound progressToken onto the prefixed prompt");
let prompt_progress = client.take_progress_notifications();
assert!(
prompt_progress.iter().any(|notification| {
notification.progress_token == prompt_marker
&& notification.message.as_deref() == Some("prompt-halfway")
}),
"as_proxy WebSocket must retain upstream prompts/get notifications/progress: {prompt_progress:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy progress close",
client.close(&cx),
)
.await
.expect("the as_proxy WebSocket progress client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_forwards_inbound_completion_progress_marker() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy completion-progress runtime installs an ambient context",
);
let upstream = spawn_modern_facade_http_server(false, None, false);
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-complete-progress-gateway".to_owned(),
"e2e-ws-as-proxy-complete-progress-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket completion-progress gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-complete-progress-upstream",
"native-h1:e2e-ws-as-proxy-complete-progress-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-complete-progress".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP completion proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP completion proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-complete-progress-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed completion-progress install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy completion-progress must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket as_proxy completion-progress publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy completion-progress serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy completion-progress handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket completion-progress must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy completion-progress initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-complete-progress", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy completion-progress over bind_websocket");
let prefixed = format!("ext/{PUBLIC_HTTP_PROMPT_NAME}");
let params = modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: prefixed,
title: "Public HTTP E2E Prompt".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
context: Some(modern::FinalCompletionContext {
arguments: Some(std::collections::BTreeMap::from([(
"region".to_owned(),
"us-east-1".to_owned(),
)])),
}),
};
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy completion silent",
client.complete(&cx, params.clone()),
)
.await
.expect("as_proxy WebSocket completion/complete still completes without a progress token");
assert!(
client.take_progress_notifications().is_empty(),
"as_proxy WebSocket must not invent completion progress without a progressToken"
);
let mut unprefixed = params.clone();
unprefixed.reference = modern::CompletionReference::PromptWithTitle {
name: PUBLIC_HTTP_PROMPT_NAME.to_owned(),
title: "Public HTTP E2E Prompt".to_owned(),
};
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy completion unprefixed",
client.complete_with_progress_marker(
&cx,
unprefixed,
modern::ProgressMarker::from("as-proxy-ws-complete-unprefixed"),
),
)
.await
.expect_err("changing only the completion prompt name must not reach the unprefixed provider");
let _ = missing;
let marker = modern::ProgressMarker::from("as-proxy-ws-complete-progress");
let result = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy completion progress",
client.complete_with_progress_marker(&cx, params, marker.clone()),
)
.await
.expect("as_proxy WebSocket must forward an inbound progressToken onto completion/complete");
assert_eq!(
result.completion.values,
vec![PUBLIC_HTTP_COMPLETION_VALUE.to_owned()],
"as_proxy WebSocket must still retain the upstream completion values: {result:?}"
);
let progress = client.take_progress_notifications();
assert!(
progress.iter().any(|notification| {
notification.progress_token == marker
&& notification.message.as_deref() == Some("completion-halfway")
}),
"as_proxy WebSocket must retain upstream completion notifications/progress: {progress:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy completion-progress close",
client.close(&cx),
)
.await
.expect("the as_proxy WebSocket completion-progress client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_as_proxy_forwards_resource_template_completion() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy template-completion runtime installs an ambient context",
);
let upstream = spawn_modern_template_completion_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-template-complete-gateway".to_owned(),
"e2e-ws-as-proxy-template-complete-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket template-completion gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-template-complete-upstream",
"native-h1:e2e-ws-as-proxy-template-complete-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-template-complete".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP template-completion proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP template-completion proxy catalog is typed");
let server =
modern::ServerBuilder::new("e2e-ws-as-proxy-template-complete-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed template-completion install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy template-completion must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy template-completion publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy template-completion serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy template-completion handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket template-completion must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy template-completion initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-template-complete", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy template-completion over bind_websocket",
);
let retained = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy template complete",
client.complete(&cx, public_http_template_completion_params()),
)
.await
.expect("as_proxy WebSocket must forward the unprefixed resource-template completion");
assert_eq!(
retained.completion.values,
vec![PUBLIC_HTTP_TEMPLATE_COMPLETION_VALUE.to_owned()],
"as_proxy WebSocket must retain the upstream template completion: {retained:?}"
);
let mut other = public_http_template_completion_params();
other.reference = modern::CompletionReference::Resource {
uri: PUBLIC_HTTP_OTHER_TEMPLATE.to_owned(),
};
let missing = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy template complete other URI",
client.complete(&cx, other),
)
.await
.expect_err("changing only the template URI must not invent a completion provider");
let _ = missing;
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy template-completion close",
client.close(&cx),
)
.await
.expect("the as_proxy WebSocket template-completion client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_advertises_completions_when_upstream_supports() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy completion-advertise runtime installs an ambient context",
);
let upstream = spawn_modern_template_completion_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-complete-advertise-gateway".to_owned(),
"e2e-ws-as-proxy-complete-advertise-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket completion-advertise gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-complete-advertise-upstream",
"native-h1:e2e-ws-as-proxy-complete-advertise-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-complete-advertise".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP template-completion proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP template-completion proxy catalog is typed");
let server =
modern::ServerBuilder::new("e2e-ws-as-proxy-complete-advertise-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed completion-advertise install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy completion-advertise must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy completion-advertise publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy completion-advertise serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy completion-advertise handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket completion-advertise must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy completion-advertise initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-complete-advertise", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy completion-advertise over bind_websocket",
);
let advertised_discovery = client
.session()
.server_discovery()
.expect("a modern as_proxy WebSocket handshake retains server/discover");
let advertised_caps = serde_json::to_value(advertised_discovery.capabilities())
.expect("modern as_proxy discovery capabilities serialize");
assert_eq!(
advertised_caps.get("completions"),
Some(&json!({})),
"as_proxy WebSocket must advertise completions when the upstream installed a provider: {advertised_caps:?}"
);
let retained = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy advertised complete",
client.complete(&cx, public_http_template_completion_params()),
)
.await
.expect("advertised as_proxy WebSocket completions must still forward the unprefixed template");
assert_eq!(
retained.completion.values,
vec![PUBLIC_HTTP_TEMPLATE_COMPLETION_VALUE.to_owned()],
"as_proxy WebSocket must retain the upstream template completion after advertising: {retained:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy completion-advertise close",
client.close(&cx),
)
.await
.expect("the as_proxy WebSocket completion-advertise client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_omits_completions_when_upstream_has_none() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy completion-omit runtime installs an ambient context",
);
let upstream = spawn_modern_as_proxy_counting_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-complete-omit-gateway".to_owned(),
"e2e-ws-as-proxy-complete-omit-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket completion-omit gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-complete-omit-upstream",
"native-h1:e2e-ws-as-proxy-complete-omit-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-complete-omit".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP counting proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-complete-omit-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed completion-omit install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy completion-omit must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy completion-omit publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy completion-omit serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy completion-omit handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket completion-omit must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy completion-omit initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-complete-omit", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy completion-omit over bind_websocket",
);
let omitted_discovery = client
.session()
.server_discovery()
.expect("a modern as_proxy WebSocket handshake retains server/discover");
let omitted_caps = serde_json::to_value(omitted_discovery.capabilities())
.expect("modern as_proxy discovery capabilities serialize");
assert!(
omitted_caps.get("completions").is_none(),
"changing only the missing upstream completion provider must omit as_proxy WebSocket completions: {omitted_caps:?}"
);
let prefixed = format!("ext/{PUBLIC_HTTP_PROMPT_NAME}");
let refused = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy omitted complete",
client.complete(
&cx,
modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: prefixed,
title: "Public HTTP E2E Prompt".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "subject".to_owned(),
value: "al".to_owned(),
},
context: None,
},
),
)
.await
.expect_err("an as_proxy WebSocket gateway without a completion provider must refuse complete");
let _ = refused;
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy completion-omit close",
client.close(&cx),
)
.await
.expect("the as_proxy WebSocket completion-omit client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", any(target_os = "linux", target_os = "macos")))]
#[test]
fn e2e_public_websocket_as_proxy_forwards_filesystem_template() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy filesystem runtime installs an ambient context",
);
let upstream = spawn_modern_filesystem_http_server_named("as-proxy-ws");
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-filesystem-gateway".to_owned(),
"e2e-ws-as-proxy-filesystem-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket filesystem gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-filesystem-upstream",
"native-h1:e2e-ws-as-proxy-filesystem-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-filesystem".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live modern HTTP filesystem proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP filesystem proxy catalog is typed");
let server = modern::ServerBuilder::new("e2e-ws-as-proxy-filesystem-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed filesystem install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy filesystem must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy filesystem publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public ModernOnly bind_websocket as_proxy filesystem serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy filesystem handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("as_proxy WebSocket filesystem must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy filesystem initialize",
modern::ClientBuilder::new()
.client_info("e2e-as-proxy-ws-filesystem", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates as_proxy filesystem over bind_websocket",
);
let listed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy filesystem resources/templates/list",
client.list_resource_templates(&cx, None),
)
.await
.expect("modern as_proxy WebSocket must list the unprefixed filesystem template");
assert!(
listed
.resource_templates
.iter()
.any(|template| template.uri_template == PUBLIC_HTTP_FS_TEMPLATE
&& template.name == PUBLIC_HTTP_FS_PREFIX),
"modern as_proxy WebSocket must keep the exact filesystem template: {:?}",
listed.resource_templates
);
assert!(
listed
.resource_templates
.iter()
.all(|template| !template.uri_template.starts_with("ext/")),
"a nonempty as_proxy prefix must not invent a non-absolute filesystem template URI: {:?}",
listed.resource_templates
);
let unmatched = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy unmatched filesystem resources/read",
client.read_resource(&cx, "file:///other/note.txt"),
)
.await
.expect_err(
"changing only the filesystem prefix must refuse before the upstream handler",
);
let _ = unmatched;
let file = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy matched filesystem resources/read",
client.read_resource(&cx, PUBLIC_HTTP_FS_FILE_URI),
)
.await
.expect("modern as_proxy WebSocket must expand the live file URI through the upstream provider");
assert!(
matches!(
file.contents.as_slice(),
[EmbeddedResourceContents::Text { text, .. }] if text == PUBLIC_HTTP_FS_FILE_TEXT
),
"modern as_proxy WebSocket must retain the upstream filesystem bytes: {:?}",
file.contents
);
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy filesystem close",
client.close(&cx),
)
.await
.expect("the modern as_proxy WebSocket filesystem client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", feature = "tasks"))]
#[test]
fn e2e_public_websocket_as_proxy_creates_official_task_with_progress_marker() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket as_proxy Tasks progress-create runtime installs an ambient context",
);
let upstream = spawn_modern_task_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-as-proxy-tasks-progress-gateway".to_owned(),
"e2e-ws-as-proxy-tasks-progress-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket Tasks progress-create gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-as-proxy-tasks-progress-upstream",
"native-h1:e2e-ws-as-proxy-tasks-progress-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-as-proxy-tasks-progress".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP Tasks proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP Tasks proxy catalog is typed");
let server =
modern::ServerBuilder::new("e2e-ws-as-proxy-tasks-progress-gateway", "1.0.0")
.as_proxy_typed("ext", proxy, catalog)
.expect("modern as_proxy_typed Tasks install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy Tasks progress-create must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket as_proxy Tasks progress-create publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy Tasks progress-create serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks progress-create handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket as_proxy Tasks progress-create must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks progress-create initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-as-proxy-tasks-progress", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy official Tasks progress-create over bind_websocket");
let prefixed = format!("ext/{PUBLIC_HTTP_TASK_TOOL_NAME}");
let marker = modern::ProgressMarker::from("ws-as-proxy-task-create-progress");
let unprefixed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks progress-create unprefixed",
client.call_tool_outcome_with_progress_marker(
&cx,
PUBLIC_HTTP_TASK_TOOL_NAME,
json!({}),
marker.clone(),
),
)
.await
.expect_err(
"changing only the tool name must not create a Task on the unprefixed path even with a progress token",
);
let _ = unprefixed;
let created = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks progress-create tools/call",
client.call_tool_outcome_with_progress_marker(&cx, &prefixed, json!({}), marker),
)
.await
.expect("a progress token must not prevent as_proxy WebSocket from creating one official Task");
let FinalToolCallOutcome::Task(created) = created else {
panic!(
"the prefixed task-capable live tool must return the official Task result branch after a progress token: {created:?}"
);
};
let task_id = created.task.base().task_id.clone();
let observed = websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks progress-create get",
client.get_task(&cx, task_id.clone()),
)
.await
.expect("typed tasks/get must return the WebSocket-gateway-created Task");
assert_eq!(
observed.task.base().task_id,
task_id,
"as_proxy WebSocket must keep the same id it created under a progress token: {observed:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket as_proxy Tasks progress-create close",
client.close(&cx),
)
.await
.expect("the modern WebSocket as_proxy Tasks progress-create client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[test]
fn e2e_public_websocket_bind_startup_and_shutdown_hooks_run_on_cooperative_drain() {
let hooked_startup = Arc::new(AtomicBool::new(false));
let hooked_shutdown = Arc::new(AtomicBool::new(false));
let bare_startup = Arc::new(AtomicBool::new(false));
let bare_shutdown = Arc::new(AtomicBool::new(false));
let hooked = spawn_modern_lifespan_websocket_server(
Some(Arc::clone(&hooked_startup)),
Some(Arc::clone(&hooked_shutdown)),
);
let bare = spawn_modern_lifespan_websocket_server(None, None);
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket lifespan runtime installs an ambient context",
);
let hooked_transport = websocket_client_bounded(
&cx,
"live modern WebSocket hooked handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{}/mcp", hooked.address())),
)
.await
.expect("hooked modern WebSocket must complete RFC 6455 upgrade");
let mut hooked_client = websocket_client_bounded(
&cx,
"live modern WebSocket hooked initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-lifespan", "1.0.0")
.connect_websocket_with_cx(&cx, hooked_transport),
)
.await
.expect("the hooked modern WebSocket peer must initialize");
websocket_client_bounded(&cx, "live modern WebSocket hooked ping", hooked_client.ping(&cx))
.await
.expect("tools traffic must be admitted after the startup hook");
assert!(
hooked_startup.load(Ordering::SeqCst),
"ModernOnly bind_websocket serve must run the public startup hook before admitting traffic"
);
assert!(
!hooked_shutdown.load(Ordering::SeqCst),
"the shutdown hook must not run while the listener is still serving"
);
let bare_transport = websocket_client_bounded(
&cx,
"live modern WebSocket unhooked handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{}/mcp", bare.address())),
)
.await
.expect("unhooked modern WebSocket must complete RFC 6455 upgrade");
let mut bare_client = websocket_client_bounded(
&cx,
"live modern WebSocket unhooked initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-lifespan-bare", "1.0.0")
.connect_websocket_with_cx(&cx, bare_transport),
)
.await
.expect("the unhooked modern WebSocket peer must initialize");
websocket_client_bounded(&cx, "live modern WebSocket unhooked ping", bare_client.ping(&cx))
.await
.expect("the unhooked peer must still admit ping");
websocket_client_bounded(&cx, "live modern WebSocket hooked close", hooked_client.close(&cx))
.await
.expect("the hooked modern WebSocket client closes");
websocket_client_bounded(&cx, "live modern WebSocket unhooked close", bare_client.close(&cx))
.await
.expect("the unhooked modern WebSocket client closes");
drop(hooked_client);
drop(bare_client);
});
hooked.shutdown();
assert!(
hooked_shutdown.load(Ordering::SeqCst),
"cooperative bind_websocket drain must run the public shutdown hook"
);
bare.shutdown();
assert!(
!bare_startup.load(Ordering::SeqCst) && !bare_shutdown.load(Ordering::SeqCst),
"omitting only the lifespan hooks must keep the peer unhooked"
);
}
fn spawn_legacy_lifespan_websocket_server(
startup: Option<Arc<AtomicBool>>,
shutdown: Option<Arc<AtomicBool>>,
) -> WebSocketServerFixture {
let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<SocketAddr, String>>(1);
let (server_cx_tx, server_cx_rx) = mpsc::sync_channel::<Cx>(1);
let (finished_tx, finished_rx) =
mpsc::sync_channel::<Result<WebSocketServerShutdown, String>>(1);
let join = Some(thread::spawn(move || {
let ready_for_spawn_failure = ready_tx.clone();
let finished_for_spawn_failure = finished_tx.clone();
let runtime = asupersync::runtime::RuntimeBuilder::current_thread()
.with_reactor(
asupersync::runtime::reactor::create_reactor()
.expect("legacy lifespan facade WebSocket server reactor initializes"),
)
.blocking_threads(4, 64)
.build()
.expect("legacy lifespan facade WebSocket server installs an owned runtime");
let outcome = runtime.block_on(async move {
let cx = Cx::current()
.expect("owned WebSocket runtime installs an ambient server context");
if server_cx_tx.send(cx.clone()).is_err() {
cx.set_cancel_requested(true);
return Err(
"legacy lifespan WebSocket server control receiver went away".to_owned(),
);
}
let builder =
legacy_2024::ServerBuilder::new("facade-ws-legacy-lifespan-join", "1.0.0")
.tool(PublicHttpValue);
let builder = match startup {
Some(flag) => builder.on_startup(move || -> Result<(), std::io::Error> {
flag.store(true, Ordering::SeqCst);
Ok(())
}),
None => builder,
};
let builder = match shutdown {
Some(flag) => builder.on_shutdown(move || {
flag.store(true, Ordering::SeqCst);
}),
None => builder,
};
let server = builder.build();
let bound = match server.bind_websocket(&cx, "127.0.0.1:0").await {
Ok(bound) => bound,
Err(error) => {
let message =
format!("legacy lifespan facade WebSocket server bind failed: {error}");
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
let address = match bound.local_addr() {
Ok(address) => address,
Err(error) => {
let message = format!(
"legacy lifespan facade WebSocket server address failed: {error}"
);
let _ = ready_tx.send(Err(message.clone()));
return Err(message);
}
};
if ready_tx.send(Ok(address)).is_err() {
cx.set_cancel_requested(true);
return Err(
"legacy lifespan WebSocket server startup receiver went away".to_owned(),
);
}
bound.serve(&cx).await.map_err(|error| {
format!("legacy lifespan facade WebSocket server stopped unexpectedly: {error}")
})
});
if let Err(message) = &outcome {
let _ = ready_for_spawn_failure.send(Err(message.clone()));
}
let _ = finished_for_spawn_failure.send(outcome);
}));
let mut server_cx = None;
let startup_deadline = Instant::now() + HTTP_SERVER_STARTUP_BOUND;
let address = loop {
if server_cx.is_none() {
if let Ok(cx) = server_cx_rx.try_recv() {
server_cx = Some(cx);
}
}
let remaining = startup_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
if let Some(cx) = server_cx.as_ref() {
cx.set_cancel_requested(true);
}
panic!("legacy lifespan facade WebSocket server startup exceeded its bound");
}
match ready_rx.recv_timeout(remaining.min(Duration::from_millis(10))) {
Ok(Ok(address)) => break address,
Ok(Err(error)) => {
panic!("legacy lifespan facade WebSocket server failed to start: {error}")
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
panic!("legacy lifespan facade WebSocket server readiness channel disconnected")
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
};
if server_cx.is_none() {
if let Ok(cx) = server_cx_rx.try_recv() {
server_cx = Some(cx);
}
}
WebSocketServerFixture {
address,
server_cx: server_cx.expect("startup retains the runtime-installed server context"),
finished: finished_rx,
shutdown_completion: None,
join,
nonquiescent: None,
}
}
#[test]
fn e2e_public_websocket_legacy_bind_startup_and_shutdown_hooks_run_on_cooperative_drain() {
let hooked_startup = Arc::new(AtomicBool::new(false));
let hooked_shutdown = Arc::new(AtomicBool::new(false));
let bare_startup = Arc::new(AtomicBool::new(false));
let bare_shutdown = Arc::new(AtomicBool::new(false));
let hooked = spawn_legacy_lifespan_websocket_server(
Some(Arc::clone(&hooked_startup)),
Some(Arc::clone(&hooked_shutdown)),
);
let bare = spawn_legacy_lifespan_websocket_server(None, None);
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket lifespan-join runtime installs an ambient context",
);
let hooked_transport = websocket_client_bounded(
&cx,
"live exact-2024 WebSocket hooked-join handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{}/mcp", hooked.address())),
)
.await
.expect("hooked exact-2024 WebSocket must complete RFC 6455 upgrade");
let mut hooked_client = websocket_client_bounded(
&cx,
"live exact-2024 WebSocket hooked-join initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-lifespan-join", "1.0.0")
.connect_websocket_with_cx(&cx, hooked_transport),
)
.await
.expect("the hooked exact-2024 WebSocket peer must initialize");
websocket_client_bounded(
&cx,
"live exact-2024 WebSocket hooked-join ping",
hooked_client.ping(&cx),
)
.await
.expect("tools traffic must be admitted after the startup hook");
assert!(
hooked_startup.load(Ordering::SeqCst),
"LegacyOnly bind_websocket serve must run the public startup hook before admitting traffic"
);
assert!(
!hooked_shutdown.load(Ordering::SeqCst),
"the shutdown hook must not run while the listener is still serving"
);
let bare_transport = websocket_client_bounded(
&cx,
"live exact-2024 WebSocket unhooked-join handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{}/mcp", bare.address())),
)
.await
.expect("unhooked exact-2024 WebSocket must complete RFC 6455 upgrade");
let mut bare_client = websocket_client_bounded(
&cx,
"live exact-2024 WebSocket unhooked-join initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-lifespan-join-bare", "1.0.0")
.connect_websocket_with_cx(&cx, bare_transport),
)
.await
.expect("the unhooked exact-2024 WebSocket peer must initialize");
websocket_client_bounded(
&cx,
"live exact-2024 WebSocket unhooked-join ping",
bare_client.ping(&cx),
)
.await
.expect("the unhooked peer must still admit ping");
websocket_client_bounded(
&cx,
"live exact-2024 WebSocket hooked-join close",
hooked_client.close(&cx),
)
.await
.expect("the hooked exact-2024 WebSocket client closes");
websocket_client_bounded(
&cx,
"live exact-2024 WebSocket unhooked-join close",
bare_client.close(&cx),
)
.await
.expect("the unhooked exact-2024 WebSocket client closes");
drop(hooked_client);
drop(bare_client);
});
hooked.shutdown();
assert!(
hooked_shutdown.load(Ordering::SeqCst),
"cooperative exact-2024 bind_websocket drain must run the public shutdown hook"
);
bare.shutdown();
assert!(
!bare_startup.load(Ordering::SeqCst) && !bare_shutdown.load(Ordering::SeqCst),
"omitting only the lifespan hooks must keep the exact-2024 peer unhooked"
);
}
#[test]
fn e2e_public_websocket_legacy_bind_admits_ping_and_tools_call() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket runtime installs an ambient context");
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy", "1.0.0")
.tool(PublicWebSocketLogTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 bind_websocket handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 bind_websocket initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates over bind_websocket");
assert_eq!(
client.protocol_version(),
"2024-11-05",
"the sealed exact-2024 WebSocket session must pin 2024-11-05"
);
websocket_client_bounded(&cx, "live exact-2024 bind_websocket ping", client.ping(&cx))
.await
.expect("live LegacyOnly bind_websocket must answer ping");
let listed = websocket_client_bounded(
&cx,
"live exact-2024 bind_websocket tools/list",
client.list_tools(&cx),
)
.await
.expect("live LegacyOnly bind_websocket must list the registered tool");
assert!(
listed.iter().any(|tool| tool.name == PUBLIC_WS_LOG_TOOL_NAME),
"exact-2024 WebSocket tools/list must retain the registered tool: {listed:?}"
);
let called = websocket_client_bounded(
&cx,
"live exact-2024 bind_websocket tools/call",
client.call_tool(&cx, PUBLIC_WS_LOG_TOOL_NAME, json!({})),
)
.await
.expect("live LegacyOnly bind_websocket must reach the registered tool");
assert_eq!(
legacy_http_tool_text(&called).as_deref(),
Some("logged"),
"exact-2024 WebSocket tools/call must retain the handler text: {called:?}"
);
let missing = websocket_client_bounded(
&cx,
"live exact-2024 bind_websocket missing tools/call",
client.call_tool(&cx, "public-ws-e2e-missing", json!({})),
)
.await
.expect_err("changing only the missing tool must keep the session refused");
assert!(
missing.code == McpErrorCode::MethodNotFound
|| missing.code == McpErrorCode::InvalidParams
|| missing.code == McpErrorCode::InvalidRequest,
"a missing exact-2024 WebSocket tool must stay a handler-visible refusal: {missing:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 bind_websocket close",
client.close(&cx),
)
.await
.expect("the exact-2024 WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_typed_verbs_honor_pre_send_cancellation() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket runtime installs an ambient context");
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-pre-send-cancel", "1.0.0")
.tool(PublicWebSocketLogTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 bind_websocket handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 bind_websocket initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-pre-send-cancel", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates over bind_websocket");
websocket_client_bounded(&cx, "live exact-2024 bind_websocket ping", client.ping(&cx))
.await
.expect("live LegacyOnly bind_websocket must answer ping before local cancellation");
let cancellation = McpRequestCancellation::new();
cancellation.cancel();
let ping = websocket_client_bounded(
&cx,
"pre-send exact-2024 WebSocket ping",
client.ping_with_cancellation(&cx, &cancellation),
)
.await
.expect_err("pre-send exact-2024 WebSocket ping cancellation must reject locally");
assert_eq!(ping.code, McpErrorCode::RequestCancelled);
let list = websocket_client_bounded(
&cx,
"pre-send exact-2024 WebSocket tools/list",
client.list_tools_with_cancellation(&cx, &cancellation, None),
)
.await
.expect_err("pre-send exact-2024 WebSocket list_tools cancellation must reject locally");
assert_eq!(list.code, McpErrorCode::RequestCancelled);
let resources = websocket_client_bounded(
&cx,
"pre-send exact-2024 WebSocket resources/list",
client.list_resources_with_cancellation(&cx, &cancellation, None),
)
.await
.expect_err(
"pre-send exact-2024 WebSocket list_resources cancellation must reject locally",
);
assert_eq!(resources.code, McpErrorCode::RequestCancelled);
let templates = websocket_client_bounded(
&cx,
"pre-send exact-2024 WebSocket resources/templates/list",
client.list_resource_templates_with_cancellation(&cx, &cancellation, None),
)
.await
.expect_err(
"pre-send exact-2024 WebSocket list_resource_templates cancellation must reject locally",
);
assert_eq!(templates.code, McpErrorCode::RequestCancelled);
let prompts = websocket_client_bounded(
&cx,
"pre-send exact-2024 WebSocket prompts/list",
client.list_prompts_with_cancellation(&cx, &cancellation, None),
)
.await
.expect_err("pre-send exact-2024 WebSocket list_prompts cancellation must reject locally");
assert_eq!(prompts.code, McpErrorCode::RequestCancelled);
let call = websocket_client_bounded(
&cx,
"pre-send exact-2024 WebSocket tools/call",
client.call_tool_with_cancellation(
&cx,
&cancellation,
PUBLIC_WS_LOG_TOOL_NAME,
json!({}),
),
)
.await
.expect_err("pre-send exact-2024 WebSocket call_tool cancellation must reject locally");
assert_eq!(call.code, McpErrorCode::RequestCancelled);
let resource = websocket_client_bounded(
&cx,
"pre-send exact-2024 WebSocket resources/read",
client.read_resource_with_cancellation(
&cx,
&cancellation,
PUBLIC_HTTP_RESOURCE_URI,
),
)
.await
.expect_err(
"pre-send exact-2024 WebSocket read_resource cancellation must reject locally",
);
assert_eq!(resource.code, McpErrorCode::RequestCancelled);
let prompt = websocket_client_bounded(
&cx,
"pre-send exact-2024 WebSocket prompts/get",
client.get_prompt_with_cancellation(
&cx,
&cancellation,
PUBLIC_HTTP_PROMPT_NAME,
HashMap::new(),
),
)
.await
.expect_err("pre-send exact-2024 WebSocket get_prompt cancellation must reject locally");
assert_eq!(prompt.code, McpErrorCode::RequestCancelled);
let completion = websocket_client_bounded(
&cx,
"pre-send exact-2024 WebSocket completion/complete",
client.complete_with_cancellation(
&cx,
&cancellation,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt {
name: PUBLIC_HTTP_PROMPT_NAME.to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
meta: None,
},
),
)
.await
.expect_err("pre-send exact-2024 WebSocket complete cancellation must reject locally");
assert_eq!(completion.code, McpErrorCode::RequestCancelled);
let admitted = McpRequestCancellation::new();
let called = websocket_client_bounded(
&cx,
"uncancelled exact-2024 WebSocket tools/call",
client.call_tool_with_cancellation(
&cx,
&admitted,
PUBLIC_WS_LOG_TOOL_NAME,
json!({}),
),
)
.await
.expect("an uncancelled exact-2024 WebSocket tools/call still reaches the handler");
assert_eq!(
legacy_http_tool_text(&called).as_deref(),
Some("logged"),
"uncancelled exact-2024 WebSocket tools/call must retain the handler text: {called:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 bind_websocket ping after local cancel",
client.ping(&cx),
)
.await
.expect("the same exact-2024 WebSocket session remains usable after local cancellation");
websocket_client_bounded(
&cx,
"live exact-2024 bind_websocket close",
client.close(&cx),
)
.await
.expect("the exact-2024 WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_reads_resource_and_gets_prompt() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket resource runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-catalog", "1.0.0")
.resource(PublicHttpSnapshotResource)
.prompt(PublicHttpInstructionPrompt)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket catalog must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket catalog publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket catalog serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 catalog handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket catalog must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 catalog initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-catalog", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates catalog verbs over bind_websocket");
let resources = websocket_client_bounded(
&cx,
"live exact-2024 bind_websocket resources/list",
client.list_resources(&cx),
)
.await
.expect("live LegacyOnly bind_websocket must list the registered resource");
assert!(
resources
.iter()
.any(|resource| resource.uri == PUBLIC_HTTP_RESOURCE_URI),
"exact-2024 WebSocket resources/list must retain the registered URI: {resources:?}"
);
let read = websocket_client_bounded(
&cx,
"live exact-2024 bind_websocket resources/read",
client.read_resource(&cx, PUBLIC_HTTP_RESOURCE_URI),
)
.await
.expect("live LegacyOnly bind_websocket must read the registered resource");
let read_text = serde_json::to_value(&read)
.ok()
.and_then(|value| value["contents"][0]["text"].as_str().map(str::to_owned));
assert_eq!(
read_text.as_deref(),
Some(PUBLIC_HTTP_RESOURCE_TEXT),
"exact-2024 WebSocket resources/read must retain the handler text: {read:?}"
);
let missing_resource = websocket_client_bounded(
&cx,
"live exact-2024 bind_websocket missing resources/read",
client.read_resource(&cx, "test://public-ws-e2e/missing"),
)
.await
.expect_err("changing only the missing resource must keep the session refused");
assert!(
missing_resource.code == McpErrorCode::MethodNotFound
|| missing_resource.code == McpErrorCode::InvalidParams
|| missing_resource.code == McpErrorCode::InvalidRequest
|| missing_resource.code == McpErrorCode::ResourceNotFound,
"a missing exact-2024 WebSocket resource must stay a handler-visible refusal: {missing_resource:?}"
);
let prompts = websocket_client_bounded(
&cx,
"live exact-2024 bind_websocket prompts/list",
client.list_prompts(&cx),
)
.await
.expect("live LegacyOnly bind_websocket must list the registered prompt");
assert!(
prompts
.iter()
.any(|prompt| prompt.name == PUBLIC_HTTP_PROMPT_NAME),
"exact-2024 WebSocket prompts/list must retain the registered prompt: {prompts:?}"
);
let prompt = websocket_client_bounded(
&cx,
"live exact-2024 bind_websocket prompts/get",
client.get_prompt(
&cx,
PUBLIC_HTTP_PROMPT_NAME,
HashMap::from([("subject".to_owned(), "cross-era".to_owned())]),
),
)
.await
.expect("live LegacyOnly bind_websocket must get the registered prompt");
let prompt_text = serde_json::to_value(&prompt)
.ok()
.and_then(|value| value["messages"][0]["content"]["text"].as_str().map(str::to_owned));
assert_eq!(
prompt_text.as_deref(),
Some(PUBLIC_HTTP_PROMPT_TEXT),
"exact-2024 WebSocket prompts/get must retain the handler text: {prompt:?}"
);
let missing_prompt = websocket_client_bounded(
&cx,
"live exact-2024 bind_websocket missing prompts/get",
client.get_prompt(&cx, "public-ws-e2e-missing", HashMap::new()),
)
.await
.expect_err("changing only the missing prompt must keep the session refused");
assert!(
missing_prompt.code == McpErrorCode::MethodNotFound
|| missing_prompt.code == McpErrorCode::InvalidParams
|| missing_prompt.code == McpErrorCode::InvalidRequest
|| missing_prompt.code == McpErrorCode::PromptNotFound,
"a missing exact-2024 WebSocket prompt must stay a handler-visible refusal: {missing_prompt:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 catalog close",
client.close(&cx),
)
.await
.expect("the exact-2024 catalog WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_retains_ctx_info_after_set_log_level() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket log runtime installs an ambient context");
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-log", "1.0.0")
.tool(PublicWebSocketLogTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket log must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket log publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket log serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 log handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket log must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 log initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-log", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates logging over bind_websocket");
websocket_client_bounded(
&cx,
"live exact-2024 log tools/call before setLevel",
client.call_tool(&cx, PUBLIC_WS_LOG_TOOL_NAME, json!({})),
)
.await
.expect("ctx.info must not prevent the exact-2024 WebSocket tool from completing");
let silent = client
.take_server_notifications()
.expect("exact-2024 WebSocket notifications before setLevel must decode");
assert!(
!silent.iter().any(|notification| {
legacy_http_log_message_is(notification, PUBLIC_WS_HANDLER_LOG_TEXT)
}),
"omitting only logging/setLevel must keep exact-2024 WebSocket ctx.info silent: {silent:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 logging/setLevel Info",
client.set_log_level(&cx, legacy_2024::LogLevel::Info),
)
.await
.expect("exact-2024 WebSocket logging/setLevel must be admitted");
websocket_client_bounded(
&cx,
"live exact-2024 log tools/call after setLevel Info",
client.call_tool(&cx, PUBLIC_WS_LOG_TOOL_NAME, json!({})),
)
.await
.expect("ctx.info must not prevent the same tools/call from completing");
let info = client
.take_server_notifications()
.expect("exact-2024 WebSocket notifications after setLevel Info must decode");
assert!(
info.iter().any(|notification| {
legacy_http_log_message_is(notification, PUBLIC_WS_HANDLER_LOG_TEXT)
}),
"live exact-2024 WebSocket must retain ctx.info after set_log_level(Info): {info:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 logging/setLevel Emergency",
client.set_log_level(&cx, legacy_2024::LogLevel::Emergency),
)
.await
.expect("raising only the logLevel floor must stay admitted");
websocket_client_bounded(
&cx,
"live exact-2024 log tools/call after setLevel Emergency",
client.call_tool(&cx, PUBLIC_WS_LOG_TOOL_NAME, json!({})),
)
.await
.expect("raising only the logLevel floor cannot break the same public tools/call");
let emergency = client
.take_server_notifications()
.expect("exact-2024 WebSocket notifications after Emergency must decode");
assert!(
!emergency.iter().any(|notification| {
legacy_http_log_message_is(notification, PUBLIC_WS_HANDLER_LOG_TEXT)
}),
"raising only the logLevel floor must suppress ctx.info: {emergency:?}"
);
websocket_client_bounded(&cx, "live exact-2024 log close", client.close(&cx))
.await
.expect("the exact-2024 log WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_composes_nested_tool_and_resource() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket compose runtime installs an ambient context");
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-compose", "1.0.0")
.tool(CountingPublicHttpValue {
counters: Arc::clone(&handler_calls),
})
.tool(PublicHttpComposeTool)
.resource(CountingPublicHttpSnapshot {
counters: Arc::clone(&handler_calls),
})
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket compose must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket compose publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket compose serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 compose handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket compose must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 compose initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-compose", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates compose over bind_websocket");
let composed = websocket_client_bounded(
&cx,
"live exact-2024 compose tools/call",
client.call_tool(
&cx,
PUBLIC_HTTP_COMPOSE_TOOL_NAME,
json!({"value": "alpha"}),
),
)
.await
.expect("live exact-2024 WebSocket must compose a nested tool call and resource read");
assert_eq!(
legacy_http_tool_text(&composed).as_deref(),
Some("compose:tool:alpha|resource:deterministic"),
"live exact-2024 WebSocket dispatch must attach request-scoped callers: {composed:?}"
);
assert_eq!(
handler_calls.snapshot().tool,
1,
"the nested tools/call must invoke the peer tool exactly once"
);
assert_eq!(
handler_calls.snapshot().resource,
1,
"the nested resources/read must invoke the peer resource exactly once"
);
let missing = websocket_client_bounded(
&cx,
"live exact-2024 compose missing nested tool",
client.call_tool(
&cx,
PUBLIC_HTTP_COMPOSE_TOOL_NAME,
json!({"value": "alpha", "tool": "public-http-e2e-missing"}),
),
)
.await;
let missing = match missing {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing.contains("public-http-e2e-missing")
|| missing.contains("compose-nested-tool")
|| missing.contains("Unknown tool")
|| missing.contains("not found"),
"the nested unknown tool must stay a handler-visible refusal: {missing}"
);
assert_eq!(
handler_calls.snapshot().tool,
1,
"an unknown nested tool name must not invoke the peer tool"
);
websocket_client_bounded(&cx, "live exact-2024 compose close", client.close(&cx))
.await
.expect("the exact-2024 compose WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_handler_timeout_refuses_late_tool_and_admits_fast_peer() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket timeout runtime installs an ambient context");
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-timeout", "1.0.0")
.tool(PublicHttpSlowTool)
.tool(PublicHttpFastTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket timeout must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket timeout publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public LegacyOnly bind_websocket timeout serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 timeout handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket timeout must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 timeout initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-timeout", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates timeout over bind_websocket");
let timed_out = websocket_client_bounded(
&cx,
"live exact-2024 late tools/call",
client.call_tool(&cx, PUBLIC_HTTP_SLOW_TOOL_NAME, json!({})),
)
.await
.expect_err("a handler that outlives its timeout must be refused");
let timed_out = format!("{timed_out:?}");
assert!(
timed_out.contains("Request timeout exceeded")
|| timed_out.contains("RequestCancelled"),
"the refused late tools/call must keep the handler-timeout error: {timed_out}"
);
let fast = websocket_client_bounded(
&cx,
"live exact-2024 fast tools/call",
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.await
.expect("changing only the tool must still be admitted after a handler timeout");
assert_eq!(
legacy_http_tool_text(&fast).as_deref(),
Some("fast"),
"the fast peer tool must still complete: {fast:?}"
);
websocket_client_bounded(&cx, "live exact-2024 timeout close", client.close(&cx))
.await
.expect("the exact-2024 timeout WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_handler_panic_is_sanitized_and_admits_fast_peer() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket panic runtime installs an ambient context");
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-panic", "1.0.0")
.tool(PublicHttpPanicTool)
.tool(PublicHttpFastTool)
.resource(PublicHttpPanicResource)
.resource(PublicHttpPlainResource)
.prompt(PublicHttpPanicPrompt)
.prompt(PublicHttpPlainPrompt)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket panic must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket panic publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public LegacyOnly bind_websocket panic serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 panic handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket panic must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 panic initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-panic", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates panic over bind_websocket");
let panicked = websocket_client_bounded(
&cx,
"live exact-2024 panic tools/call",
client.call_tool(&cx, PUBLIC_HTTP_PANIC_TOOL_NAME, json!({})),
)
.await
.expect_err("a panicking exact-2024 tools/call must stay a protocol error");
let panicked = format!("{panicked:?}");
assert!(
panicked.contains("Internal server error") || panicked.contains("InternalError"),
"an exact-2024 WebSocket handler panic must become the sanitized InternalError: {panicked}"
);
assert!(
!panicked.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"an exact-2024 WebSocket handler panic must not leak the unwind payload: {panicked}"
);
let fast = websocket_client_bounded(
&cx,
"live exact-2024 fast tools/call after panic",
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.await
.expect("changing only the tool must still be admitted after a sanitized exact-2024 panic");
assert_eq!(
legacy_http_tool_text(&fast).as_deref(),
Some("fast"),
"the fast exact-2024 WebSocket peer tool must still complete after a sanitized panic: {fast:?}"
);
let panicked_resource = websocket_client_bounded(
&cx,
"live exact-2024 panic resources/read",
client.read_resource(&cx, PUBLIC_HTTP_PANIC_RESOURCE_URI),
)
.await
.expect_err("a panicking exact-2024 resources/read must stay a protocol error");
let panicked_resource = format!("{panicked_resource:?}");
assert!(
panicked_resource.contains("Internal server error")
|| panicked_resource.contains("InternalError"),
"an exact-2024 WebSocket resource panic must become the sanitized InternalError: {panicked_resource}"
);
assert!(
!panicked_resource.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"an exact-2024 WebSocket resource panic must not leak the unwind payload: {panicked_resource}"
);
let peer_resource = websocket_client_bounded(
&cx,
"live exact-2024 peer resources/read after panic",
client.read_resource(&cx, PUBLIC_HTTP_PLAIN_RESOURCE_URI),
)
.await
.expect("changing only the resource must still be admitted after a sanitized exact-2024 panic");
let peer_resource = format!("{peer_resource:?}");
assert!(
peer_resource.contains("plain-resource"),
"the exact-2024 WebSocket peer resource must still complete after a sanitized catalog panic: {peer_resource}"
);
let panicked_prompt = websocket_client_bounded(
&cx,
"live exact-2024 panic prompts/get",
client.get_prompt(&cx, PUBLIC_HTTP_PANIC_PROMPT_NAME, HashMap::new()),
)
.await
.expect_err("a panicking exact-2024 prompts/get must stay a protocol error");
let panicked_prompt = format!("{panicked_prompt:?}");
assert!(
panicked_prompt.contains("Internal server error")
|| panicked_prompt.contains("InternalError"),
"an exact-2024 WebSocket prompt panic must become the sanitized InternalError: {panicked_prompt}"
);
assert!(
!panicked_prompt.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"an exact-2024 WebSocket prompt panic must not leak the unwind payload: {panicked_prompt}"
);
let peer_prompt = websocket_client_bounded(
&cx,
"live exact-2024 peer prompts/get after panic",
client.get_prompt(&cx, PUBLIC_HTTP_PLAIN_PROMPT_NAME, HashMap::new()),
)
.await
.expect("changing only the prompt must still be admitted after a sanitized exact-2024 panic");
let peer_prompt = format!("{peer_prompt:?}");
assert!(
peer_prompt.contains("plain") || !peer_prompt.is_empty(),
"the exact-2024 WebSocket peer prompt must still complete after a sanitized catalog panic: {peer_prompt}"
);
websocket_client_bounded(&cx, "live exact-2024 panic close", client.close(&cx))
.await
.expect("the exact-2024 panic WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_completion_panic_is_sanitized_and_admits_fast_peer() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket completion-panic runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-completion-panic", "1.0.0")
.tool(PublicHttpFastTool)
.prompt(PublicHttpCompletePeerPrompt)
.completion_handler(PublicHttpPanicCompletion)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket completion panic must bind a localhost listener");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket completion panic publishes its bound address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public LegacyOnly bind_websocket completion panic serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 completion-panic handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket completion panic must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 completion-panic initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-completion-panic", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates completion panic over bind_websocket");
let panicked = websocket_client_bounded(
&cx,
"live exact-2024 panic completion/complete",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt {
name: PUBLIC_HTTP_COMPLETE_PEER_PROMPT_NAME.to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
meta: None,
},
),
)
.await;
let panicked = match panicked {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
panicked.contains("Internal server error") || panicked.contains("InternalError"),
"an exact-2024 WebSocket completion panic must become the sanitized InternalError: {panicked}"
);
assert!(
!panicked.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"an exact-2024 WebSocket completion panic must not leak the unwind payload: {panicked}"
);
let peer_prompt = websocket_client_bounded(
&cx,
"live exact-2024 peer prompts/get after completion panic",
client.get_prompt(&cx, PUBLIC_HTTP_COMPLETE_PEER_PROMPT_NAME, HashMap::new()),
)
.await
.expect("changing only the method must still admit prompts/get after a sanitized exact-2024 completion panic");
let peer_prompt = format!("{peer_prompt:?}");
assert!(
peer_prompt.contains("complete-peer"),
"the exact-2024 WebSocket peer prompt must still complete after a sanitized completion panic: {peer_prompt}"
);
let fast = websocket_client_bounded(
&cx,
"live exact-2024 fast tools/call after completion panic",
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.await
.expect("changing only the method must still admit tools/call after a sanitized exact-2024 completion panic");
assert_eq!(
legacy_http_tool_text(&fast).as_deref(),
Some("fast"),
"the fast exact-2024 WebSocket peer tool must still complete after a sanitized completion panic: {fast:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 completion-panic close",
client.close(&cx),
)
.await
.expect("the exact-2024 completion-panic WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_retains_tools_list_changed() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket list_changed runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-listen", "1.0.0")
.tool(PublicWebSocketTouchTool)
.tool(PublicWebSocketHideTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket listen must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket listen publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket listen serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 list_changed handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket listen must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 list_changed initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-listen", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates catalog notifications over bind_websocket");
websocket_client_bounded(
&cx,
"live exact-2024 non-mutating tools/call",
client.call_tool(&cx, PUBLIC_WS_TOUCH_TOOL_NAME, json!({})),
)
.await
.expect("a non-mutating exact-2024 tools/call must complete");
let silent = client
.take_server_notifications()
.expect("exact-2024 WebSocket notifications before hide must decode");
assert!(
!silent.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ToolsListChanged
)),
"changing only the missing catalog mutation must keep tools/list_changed silent: {silent:?}"
);
let hidden = websocket_client_bounded(
&cx,
"live exact-2024 hide tools/call",
client.call_tool(&cx, PUBLIC_WS_HIDE_TOOL_NAME, json!({})),
)
.await
.expect("disabling a peer tool must complete");
assert_eq!(
legacy_http_tool_text(&hidden).as_deref(),
Some("hidden"),
"exact-2024 WebSocket session state must let disable_tool publish list_changed: {hidden:?}"
);
let changed = client
.take_server_notifications()
.expect("exact-2024 WebSocket notifications after hide must decode");
assert!(
changed.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ToolsListChanged
)),
"live exact-2024 WebSocket must retain notifications/tools/list_changed: {changed:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 list_changed tools/list",
client.list_tools(&cx),
)
.await
.expect("a later tools/list on the same WebSocket client must still complete");
websocket_client_bounded(&cx, "live exact-2024 list_changed close", client.close(&cx))
.await
.expect("the exact-2024 list_changed WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_retains_resource_updated_after_subscribe() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket subscribe runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-updated", "1.0.0")
.resource(PublicWebSocketWatchResource)
.tool(PublicWebSocketNotifyTool)
.tool(PublicWebSocketMissTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket updated must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket updated publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket updated serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 updated handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket updated must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 updated initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-updated", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates subscribe over bind_websocket");
let silent_touch = websocket_client_bounded(
&cx,
"live exact-2024 updated touch without subscribe",
client.call_tool(&cx, PUBLIC_WS_NOTIFY_TOOL_NAME, json!({})),
)
.await
.expect("touching an unsubscribed resource must complete");
assert_eq!(
legacy_http_tool_text(&silent_touch).as_deref(),
Some("silent"),
"without resources/subscribe the handler must not claim updated delivery: {silent_touch:?}"
);
let silent = client
.take_server_notifications()
.expect("exact-2024 WebSocket notifications before subscribe must decode");
assert!(
!silent.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ResourceUpdated(_)
)),
"omitting only resources/subscribe must keep resources/updated silent: {silent:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 resources/subscribe",
client.subscribe_resource(&cx, PUBLIC_WS_WATCH_RESOURCE_URI),
)
.await
.expect("exact-2024 WebSocket resources/subscribe must be admitted");
let missed = websocket_client_bounded(
&cx,
"live exact-2024 unmatched notify",
client.call_tool(&cx, PUBLIC_WS_MISS_TOOL_NAME, json!({})),
)
.await
.expect("notifying an unwatched URI must complete");
assert_eq!(
legacy_http_tool_text(&missed).as_deref(),
Some("silent"),
"an unmatched notify must not claim delivery: {missed:?}"
);
let unmatched = client
.take_server_notifications()
.expect("exact-2024 WebSocket notifications after unmatched notify must decode");
assert!(
!unmatched.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ResourceUpdated(_)
)),
"changing only the URI must keep resources/updated silent: {unmatched:?}"
);
let touched = websocket_client_bounded(
&cx,
"live exact-2024 matching notify",
client.call_tool(&cx, PUBLIC_WS_NOTIFY_TOOL_NAME, json!({})),
)
.await
.expect("touching a subscribed resource must complete");
assert_eq!(
legacy_http_tool_text(&touched).as_deref(),
Some("notified"),
"resources/subscribe must count as notify_resource_updated delivery: {touched:?}"
);
let updated = client
.take_server_notifications()
.expect("exact-2024 WebSocket notifications after matching notify must decode");
assert!(
updated.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ResourceUpdated(params)
if params.uri == PUBLIC_WS_WATCH_RESOURCE_URI
)),
"live exact-2024 WebSocket must retain notifications/resources/updated: {updated:?}"
);
websocket_client_bounded(&cx, "live exact-2024 updated close", client.close(&cx))
.await
.expect("the exact-2024 updated WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_sampling_callback_reaches_context() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket sampling runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-sample", "1.0.0")
.tool(PublicHttpSampleTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket sampling must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket sampling publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket sampling serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 sampling handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket sampling must complete RFC 6455 upgrade");
let callback_calls = Arc::new(AtomicUsize::new(0));
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 sampling initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-sample", "1.0.0")
.capabilities(ClientCapabilities {
sampling: Some(Default::default()),
elicitation: None,
roots: None,
})
.reverse_request_handlers(
legacy_2024::LegacyReverseRequestHandlers::new()
.with_sampling_create_message({
let callback_calls = Arc::clone(&callback_calls);
move |_cx, _cancel, _params| {
callback_calls.fetch_add(1, Ordering::Relaxed);
Box::pin(async {
Ok(legacy_2024::LegacyCreateMessageResult::text(
PUBLIC_HTTP_SAMPLED_TEXT,
"e2e-legacy-ws-model",
))
})
}
}),
)
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the sampling callback is configured before exact-2024 WebSocket initialization");
let sampled = websocket_client_bounded(
&cx,
"live exact-2024 sampling tools/call",
client.call_tool(&cx, PUBLIC_HTTP_SAMPLE_TOOL_NAME, json!({})),
)
.await
.expect("the sealed exact-2024 WebSocket facade must service sampling/createMessage before its typed tool result");
assert_eq!(
legacy_http_tool_text(&sampled).as_deref(),
Some(PUBLIC_HTTP_SAMPLED_TEXT),
"live exact-2024 WebSocket sampling must retain the callback text: {sampled:?}"
);
assert_eq!(
callback_calls.load(Ordering::Relaxed),
1,
"the tool's context authority issues exactly one sampling/createMessage callback"
);
websocket_client_bounded(&cx, "live exact-2024 sampling close", client.close(&cx))
.await
.expect("the exact-2024 sampling WebSocket client closes after the live proof");
drop(client);
let bare_transport = websocket_client_bounded(
&cx,
"live exact-2024 sampling bare handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("a bare exact-2024 WebSocket client must complete RFC 6455 upgrade");
let mut bare = websocket_client_bounded(
&cx,
"live exact-2024 sampling bare initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-sample-bare", "1.0.0")
.connect_websocket_with_cx(&cx, bare_transport),
)
.await
.expect("a bare exact-2024 WebSocket peer must still initialize");
let missing = websocket_client_bounded(
&cx,
"live exact-2024 sampling tools/call without capability",
bare.call_tool(&cx, PUBLIC_HTTP_SAMPLE_TOOL_NAME, json!({})),
)
.await
.expect("missing sampling authority remains a typed exact-2024 tool result");
let text = legacy_http_tool_text(&missing).unwrap_or_default();
assert!(
missing.is_error || text.contains("does not support sampling capability"),
"omitting only the sampling capability must fail closed: {missing:?}"
);
assert_ne!(
text.as_str(),
PUBLIC_HTTP_SAMPLED_TEXT,
"a bare peer must not invent a sampling callback result: {missing:?}"
);
websocket_client_bounded(&cx, "live exact-2024 sampling bare close", bare.close(&cx))
.await
.expect("the bare exact-2024 sampling WebSocket client closes after the planted refusal");
drop(bare);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_roots_callback_reaches_context() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket roots runtime installs an ambient context");
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-roots", "1.0.0")
.tool(PublicHttpLegacyRootsTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket roots must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket roots publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket roots serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 roots handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket roots must complete RFC 6455 upgrade");
let callback_calls = Arc::new(AtomicUsize::new(0));
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 roots initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-roots", "1.0.0")
.capabilities(ClientCapabilities {
sampling: None,
elicitation: None,
roots: Some(Default::default()),
})
.reverse_request_handlers(
legacy_2024::LegacyReverseRequestHandlers::new().with_roots_list({
let callback_calls = Arc::clone(&callback_calls);
move |_cx, _cancel, _params| {
callback_calls.fetch_add(1, Ordering::Relaxed);
Box::pin(async {
Ok(legacy_2024::ListRootsResult::new(vec![
legacy_2024::Root::with_name(
PUBLIC_HTTP_LEGACY_ROOT_URI,
"workspace",
),
]))
})
}
}),
)
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the roots callback is configured before exact-2024 WebSocket initialization");
let rooted = websocket_client_bounded(
&cx,
"live exact-2024 roots tools/call",
client.call_tool(&cx, PUBLIC_HTTP_LEGACY_ROOTS_TOOL_NAME, json!({})),
)
.await
.expect("the sealed exact-2024 WebSocket facade must service roots/list before its typed tool result");
assert_eq!(
legacy_http_tool_text(&rooted).as_deref(),
Some(PUBLIC_HTTP_LEGACY_ROOT_URI),
"live exact-2024 WebSocket roots must retain the callback URI: {rooted:?}"
);
assert_eq!(
callback_calls.load(Ordering::Relaxed),
1,
"the tool's context authority issues exactly one roots/list callback"
);
websocket_client_bounded(&cx, "live exact-2024 roots close", client.close(&cx))
.await
.expect("the exact-2024 roots WebSocket client closes after the live proof");
drop(client);
let bare_transport = websocket_client_bounded(
&cx,
"live exact-2024 roots bare handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("a bare exact-2024 WebSocket client must complete RFC 6455 upgrade");
let mut bare = websocket_client_bounded(
&cx,
"live exact-2024 roots bare initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-roots-bare", "1.0.0")
.connect_websocket_with_cx(&cx, bare_transport),
)
.await
.expect("a bare exact-2024 WebSocket peer must still initialize");
let missing = websocket_client_bounded(
&cx,
"live exact-2024 roots tools/call without capability",
bare.call_tool(&cx, PUBLIC_HTTP_LEGACY_ROOTS_TOOL_NAME, json!({})),
)
.await
.expect("missing roots authority remains a typed exact-2024 tool result");
let text = legacy_http_tool_text(&missing).unwrap_or_default();
assert!(
missing.is_error || text.contains("does not support roots capability"),
"omitting only the roots capability must fail closed: {missing:?}"
);
assert_ne!(
text.as_str(),
PUBLIC_HTTP_LEGACY_ROOT_URI,
"a bare peer must not invent a roots callback result: {missing:?}"
);
websocket_client_bounded(&cx, "live exact-2024 roots bare close", bare.close(&cx))
.await
.expect("the bare exact-2024 roots WebSocket client closes after the planted refusal");
drop(bare);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_roots_list_changed_is_admitted_and_unadvertised_peer_is_refused()
{
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket roots-change runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-roots-changed", "1.0.0")
.tool(PublicHttpValue)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect(
"public LegacyOnly bind_websocket roots-change must bind a localhost listener",
);
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket roots-change publishes its bound address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket roots-change serve must be admitted");
let advertised_transport = websocket_client_bounded(
&cx,
"live exact-2024 roots-change handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket roots-change must complete RFC 6455 upgrade");
let mut admitted = websocket_client_bounded(
&cx,
"live exact-2024 roots-change initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-roots-changed", "1.0.0")
.capabilities(ClientCapabilities {
roots: Some(legacy_2024::RootsCapability { list_changed: true }),
..ClientCapabilities::default()
})
.reverse_request_handlers(
legacy_2024::LegacyReverseRequestHandlers::new().with_roots_list(
|_cx, _cancel, _params| {
Box::pin(async {
Ok(legacy_2024::ListRootsResult::new(Vec::new()))
})
},
),
)
.connect_websocket_with_cx(&cx, advertised_transport),
)
.await
.expect("an advertised roots.listChanged WebSocket client connects");
websocket_client_bounded(
&cx,
"live exact-2024 WebSocket roots/list_changed",
admitted.roots_list_changed(&cx),
)
.await
.expect(
"live exact-2024 WebSocket must admit notifications/roots/list_changed when advertised",
);
websocket_client_bounded(
&cx,
"live exact-2024 WebSocket ping after roots/list_changed",
admitted.ping(&cx),
)
.await
.expect("the same socket must still admit ping after roots/list_changed");
let omitted_transport = websocket_client_bounded(
&cx,
"live exact-2024 roots-change bare handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("a bare exact-2024 WebSocket client must complete RFC 6455 upgrade");
let mut refused = websocket_client_bounded(
&cx,
"live exact-2024 roots-change bare initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-roots-changed-bare", "1.0.0")
.connect_websocket_with_cx(&cx, omitted_transport),
)
.await
.expect("a bare exact-2024 WebSocket peer must still initialize");
let missing = websocket_client_bounded(
&cx,
"live exact-2024 unadvertised roots/list_changed",
refused.roots_list_changed(&cx),
)
.await
.expect_err("changing only the missing roots.listChanged advertisement must refuse");
let missing = format!("{missing:?}");
assert!(
missing.contains("roots.listChanged") || missing.contains("InvalidRequest"),
"the unadvertised refusal must keep the capability gate: {missing}"
);
websocket_client_bounded(
&cx,
"live exact-2024 WebSocket ping after unadvertised roots/list_changed",
refused.ping(&cx),
)
.await
.expect("changing only the missing advertisement must leave ping admitted");
websocket_client_bounded(&cx, "live exact-2024 roots-change close", admitted.close(&cx))
.await
.expect("the advertised exact-2024 roots-change WebSocket client closes");
websocket_client_bounded(
&cx,
"live exact-2024 roots-change bare close",
refused.close(&cx),
)
.await
.expect("the bare exact-2024 roots-change WebSocket client closes");
drop(admitted);
drop(refused);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_complete_is_retained_and_peer_without_handler_is_refused() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket complete runtime installs an ambient context",
);
let advertised = legacy_2024::ServerBuilder::new("facade-ws-legacy-complete", "1.0.0")
.prompt(PublicHttpCatalogPrompt)
.completion_handler(CountingPublicHttpCompletion {
counters: Arc::new(PublicHttpHandlerCallCounters::default()),
})
.build();
let advertised_bound = advertised
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket complete must bind a localhost listener");
let advertised_address = advertised_bound.local_addr().expect(
"public LegacyOnly bind_websocket complete publishes its bound address",
);
let omitted = legacy_2024::ServerBuilder::new(
"facade-ws-legacy-complete-omitted",
"1.0.0",
)
.prompt(PublicHttpCatalogPrompt)
.build();
let omitted_bound = omitted
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("the omitted-completion WebSocket server must bind");
let omitted_address = omitted_bound
.local_addr()
.expect("the omitted-completion WebSocket server publishes its bound address");
let scope = cx.scope();
let advertised_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
advertised_bound.serve(&serve_cx).await
})
.expect("advertised complete serve must be admitted");
let omitted_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
omitted_bound.serve(&serve_cx).await
})
.expect("omitted complete serve must be admitted");
let advertised_transport = websocket_client_bounded(
&cx,
"live exact-2024 complete handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{advertised_address}/mcp")),
)
.await
.expect("advertised complete WebSocket must complete RFC 6455 upgrade");
let mut advertised_client = websocket_client_bounded(
&cx,
"live exact-2024 complete initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-complete", "1.0.0")
.connect_websocket_with_cx(&cx, advertised_transport),
)
.await
.expect("the advertised complete WebSocket peer must initialize");
assert!(
advertised_client
.server_capabilities()
.completions
.is_some(),
"installing a legacy completion handler must advertise completions: {:?}",
advertised_client.server_capabilities()
);
let omitted_transport = websocket_client_bounded(
&cx,
"live exact-2024 complete omitted handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{omitted_address}/mcp")),
)
.await
.expect("omitted complete WebSocket must complete RFC 6455 upgrade");
let mut omitted_client = websocket_client_bounded(
&cx,
"live exact-2024 complete omitted initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-complete-omitted", "1.0.0")
.connect_websocket_with_cx(&cx, omitted_transport),
)
.await
.expect("the omitted complete WebSocket peer must initialize");
assert!(
omitted_client.server_capabilities().completions.is_none(),
"changing only the missing completion handler must omit completions: {:?}",
omitted_client.server_capabilities()
);
let params = legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt {
name: PUBLIC_HTTP_CATALOG_PROMPT_NAME.to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
meta: None,
};
let completed = websocket_client_bounded(
&cx,
"live exact-2024 WebSocket completion/complete",
advertised_client.complete(&cx, params.clone()),
)
.await
.expect("live exact-2024 WebSocket must complete when completions are advertised");
assert_eq!(
completed.completion.values,
vec![PUBLIC_HTTP_COMPLETION_VALUE.to_owned()],
"the advertised exact-2024 completion provider must retain its values: {completed:?}"
);
let silent = advertised_client
.take_server_notifications()
.expect("exact-2024 WebSocket notifications without a token must decode");
assert!(
!silent.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"without a progressToken the exact-2024 WebSocket completion must stay silent: {silent:?}"
);
let marker = legacy_2024::ProgressMarker::from("ws-legacy-completion-progress");
let progressed = websocket_client_bounded(
&cx,
"live exact-2024 WebSocket completion/complete with token",
advertised_client.complete_with_progress_marker(&cx, params.clone(), marker.clone()),
)
.await
.expect("a progressToken must not prevent the exact-2024 WebSocket completion from completing");
assert_eq!(
progressed.completion.values,
vec![PUBLIC_HTTP_COMPLETION_VALUE.to_owned()],
"the progress-marked exact-2024 WebSocket completion must retain its values: {progressed:?}"
);
let progress = advertised_client
.take_server_notifications()
.expect("exact-2024 WebSocket completion progress notifications must decode");
assert!(
progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == marker
&& params.message.as_deref() == Some("completion-legacy-halfway")
)),
"live exact-2024 WebSocket must retain completion notifications/progress after a progressToken: {progress:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 WebSocket complete without handler",
omitted_client.complete(&cx, params),
)
.await
.expect_err("changing only the missing completion handler must refuse complete");
websocket_client_bounded(
&cx,
"live exact-2024 complete advertised close",
advertised_client.close(&cx),
)
.await
.expect("the advertised complete WebSocket client closes after the live proof");
websocket_client_bounded(
&cx,
"live exact-2024 complete omitted close",
omitted_client.close(&cx),
)
.await
.expect("the omitted complete WebSocket client closes after the planted refusal");
drop(advertised_client);
drop(omitted_client);
cx.set_cancel_requested(true);
advertised_listener.abort();
omitted_listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_resource_template_completion_is_retained_and_unregistered_template_is_refused()
{
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket template-complete runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new(
"facade-ws-legacy-template-complete",
"1.0.0",
)
.tool(PublicHttpFastTool)
.resource(PublicHttpCursorTemplateResource)
.resource(PublicHttpOtherTemplateResource)
.resource_template_completion_handler(
PUBLIC_HTTP_CURSOR_TEMPLATE,
PublicHttpTemplateCompletion,
)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect(
"public LegacyOnly bind_websocket template complete must bind a localhost listener",
);
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket template complete publishes its bound address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("legacy template-complete serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 template-complete handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("template-complete exact-2024 WebSocket must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 template-complete initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-template-complete", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the template-complete exact-2024 WebSocket peer must initialize");
assert!(
client.server_capabilities().completions.is_some(),
"installing a legacy resource-template completion provider must advertise completions: {:?}",
client.server_capabilities()
);
let completed = websocket_client_bounded(
&cx,
"live exact-2024 template completion/complete",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Resource {
uri: PUBLIC_HTTP_CURSOR_TEMPLATE.to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "id".to_owned(),
value: "al".to_owned(),
},
meta: None,
},
),
)
.await
.expect("live exact-2024 WebSocket must complete the registered resource template provider");
assert_eq!(
completed.completion.values,
vec![PUBLIC_HTTP_TEMPLATE_COMPLETION_LEGACY_VALUE.to_owned()],
"the exact-2024 WebSocket template provider must retain its values: {completed:?}"
);
let missing = websocket_client_bounded(
&cx,
"live exact-2024 unregistered template completion",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Resource {
uri: PUBLIC_HTTP_OTHER_TEMPLATE.to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "id".to_owned(),
value: "al".to_owned(),
},
meta: None,
},
),
)
.await
.expect_err("changing only the template URI must refuse a missing exact-2024 provider");
let missing = format!("{missing:?}");
assert!(
missing.contains("MethodNotFound") || missing.contains("InvalidParams"),
"an unregistered exact-2024 WebSocket template completion must stay refused: {missing}"
);
let fast = websocket_client_bounded(
&cx,
"live exact-2024 fast tools/call after template completion",
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.await
.expect("changing only the method must still admit tools/call after template completion");
assert_eq!(
legacy_http_tool_text(&fast).as_deref(),
Some("fast"),
"the fast exact-2024 WebSocket peer tool must still complete after template completion: {fast:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 template-complete close",
client.close(&cx),
)
.await
.expect("the exact-2024 template-complete WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_composes_nested_prompt_and_refuses_missing_name() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket compose-prompt runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-compose-prompt", "1.0.0")
.tool(PublicHttpPromptCompose)
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.prompt(PublicHttpFromPromptPrompt)
.resource(PublicHttpResourceComposesPromptResource)
.resource(PublicHttpResourceComposesMissingPromptResource)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket compose-prompt must bind");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket compose-prompt publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("legacy compose-prompt serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 compose-prompt handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("compose-prompt exact-2024 WebSocket must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 compose-prompt initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-compose-prompt", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the compose-prompt exact-2024 WebSocket peer must initialize");
let composed = websocket_client_bounded(
&cx,
"live exact-2024 compose_prompt",
client.call_tool(
&cx,
PUBLIC_HTTP_PROMPT_COMPOSE_TOOL_NAME,
json!({"subject": "alpha"}),
),
)
.await
.expect("live exact-2024 WebSocket compose_prompt must nest the instruction prompt");
assert_eq!(
legacy_http_tool_text(&composed).as_deref(),
Some("compose-prompt:prompt:alpha"),
"compose_prompt must retain the nested prompt text: {composed:?}"
);
let missing = websocket_client_bounded(
&cx,
"live exact-2024 missing nested prompt",
client.call_tool(
&cx,
PUBLIC_HTTP_PROMPT_COMPOSE_TOOL_NAME,
json!({
"subject": "alpha",
"prompt": "public-http-e2e-missing",
}),
),
)
.await;
let missing = match missing {
Ok(result) if result.is_error => format!("{result:?}"),
Err(error) => format!("{error:?}"),
Ok(result) => panic!("changing only the nested prompt name must refuse: {result:?}"),
};
assert!(
missing.contains("public-http-e2e-missing")
|| missing.contains("compose-nested-prompt")
|| missing.contains("not found"),
"the nested unknown prompt must stay a handler-visible refusal: {missing}"
);
let fast = websocket_client_bounded(
&cx,
"live exact-2024 fast tools/call after prompt compose",
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.await
.expect("changing only the method must still admit tools/call after prompt compose");
assert_eq!(
legacy_http_tool_text(&fast).as_deref(),
Some("fast"),
"the fast exact-2024 WebSocket peer tool must still complete after prompt compose: {fast:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 compose-prompt close",
client.close(&cx),
)
.await
.expect("the exact-2024 compose-prompt WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_resource_and_prompt_compose_nested_prompt() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket catalog compose-prompt runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new(
"facade-ws-legacy-catalog-compose-prompt",
"1.0.0",
)
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.prompt(PublicHttpFromPromptPrompt)
.resource(PublicHttpResourceComposesPromptResource)
.resource(PublicHttpResourceComposesMissingPromptResource)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket catalog compose-prompt must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket catalog compose-prompt publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("legacy catalog compose-prompt serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 catalog compose-prompt handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("catalog compose-prompt exact-2024 WebSocket must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 catalog compose-prompt initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-catalog-compose-prompt", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the catalog compose-prompt exact-2024 WebSocket peer must initialize");
let resource = websocket_client_bounded(
&cx,
"live exact-2024 resource compose-prompt",
client.read_resource(&cx, PUBLIC_HTTP_PROMPT_COMPOSE_RESOURCE_URI),
)
.await
.expect("live exact-2024 WebSocket resources/read must nest the instruction prompt");
let resource = serde_json::to_value(resource)
.expect("the exact-2024 WebSocket resource result serializes");
assert_eq!(
resource["contents"][0]["text"],
json!("compose-prompt:prompt:alpha"),
"exact-2024 WebSocket resource ctx.get_prompt must retain the nested prompt text: {resource}"
);
let missing_resource = websocket_client_bounded(
&cx,
"live exact-2024 missing nested prompt from resources/read",
client.read_resource(&cx, PUBLIC_HTTP_PROMPT_COMPOSE_MISSING_RESOURCE_URI),
)
.await;
let missing_resource = match missing_resource {
Ok(result) => panic!(
"changing only the nested prompt name must refuse resources/read: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
nested_prompt_refusal(&missing_resource),
"the nested unknown prompt must stay a handler-visible resource refusal: {missing_resource}"
);
let prompt = websocket_client_bounded(
&cx,
"live exact-2024 prompt compose-prompt",
client.get_prompt(
&cx,
PUBLIC_HTTP_FROM_PROMPT_NAME,
HashMap::from([("subject".to_owned(), "alpha".to_owned())]),
),
)
.await
.expect("live exact-2024 WebSocket prompts/get must nest the instruction prompt");
let prompt = serde_json::to_value(prompt)
.expect("the exact-2024 WebSocket prompt result serializes");
assert_eq!(
prompt["messages"][0]["content"]["text"],
json!("compose-prompt:prompt:alpha"),
"exact-2024 WebSocket prompt ctx.get_prompt must retain the nested prompt text: {prompt}"
);
let missing_prompt = websocket_client_bounded(
&cx,
"live exact-2024 missing nested prompt from prompts/get",
client.get_prompt(
&cx,
PUBLIC_HTTP_FROM_PROMPT_NAME,
HashMap::from([
("subject".to_owned(), "alpha".to_owned()),
("prompt".to_owned(), "public-http-e2e-missing".to_owned()),
]),
),
)
.await;
let missing_prompt = match missing_prompt {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
nested_prompt_refusal(&missing_prompt),
"the nested unknown prompt must stay a handler-visible prompt refusal: {missing_prompt}"
);
let fast = websocket_client_bounded(
&cx,
"live exact-2024 fast tools/call after catalog prompt compose",
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.await
.expect("changing only the method must still admit tools/call after catalog prompt compose");
assert_eq!(
legacy_http_tool_text(&fast).as_deref(),
Some("fast"),
"the fast exact-2024 WebSocket peer tool must still complete after catalog prompt compose: {fast:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 catalog compose-prompt close",
client.close(&cx),
)
.await
.expect("the exact-2024 catalog compose-prompt WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_static_token_refuses_missing_and_wrong_and_commits_subject()
{
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket auth runtime installs an ambient context");
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let verifier = StaticTokenVerifier::new([(
"alpha",
AuthContext::with_subject(PUBLIC_HTTP_AUTH_SUBJECT),
)])
.expect("the deterministic exact-2024 WebSocket bearer verifier is valid")
.with_allowed_schemes(["Bearer"])
.expect("the bearer scheme allowlist is valid");
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-auth", "1.0.0")
.auth_provider(TokenAuthProvider::new(verifier))
.tool(PublicHttpAuthSubject {
counters: Arc::clone(&handler_calls),
})
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket auth must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket auth publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket auth serve must be admitted");
let (missing, _missing_stream) = websocket_upgrade_status(&cx, address, None).await;
assert!(
missing.starts_with(b"HTTP/1.1 401"),
"omitting Authorization must refuse the exact-2024 WebSocket upgrade: {}",
String::from_utf8_lossy(&missing)
);
assert!(
String::from_utf8_lossy(&missing)
.to_ascii_lowercase()
.contains("www-authenticate"),
"the missing-token exact-2024 WebSocket upgrade must challenge Bearer: {}",
String::from_utf8_lossy(&missing)
);
let (wrong, _wrong_stream) =
websocket_upgrade_status(&cx, address, Some("Bearer wrong")).await;
assert!(
wrong.starts_with(b"HTTP/1.1 401"),
"a wrong bearer token must refuse the exact-2024 WebSocket upgrade: {}",
String::from_utf8_lossy(&wrong)
);
let (accepted, stream) =
websocket_upgrade_status(&cx, address, Some("Bearer alpha")).await;
assert!(
accepted.starts_with(b"HTTP/1.1 101"),
"the matching bearer token must complete RFC 6455 upgrade: {}",
String::from_utf8_lossy(&accepted)
);
let transport = AsyncWsClientTransport::from_upgraded(stream);
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 auth initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-auth", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("an admitted WebSocket upgrade must negotiate LegacyOnly");
let admitted = websocket_client_bounded(
&cx,
"live exact-2024 auth tools/call",
client.call_tool(&cx, PUBLIC_HTTP_AUTH_TOOL_NAME, json!({})),
)
.await
.expect("the matching bearer token must reach the authenticated handler");
let expected_subject = format!("subject:{PUBLIC_HTTP_AUTH_SUBJECT}");
assert_eq!(
legacy_http_tool_text(&admitted).as_deref(),
Some(expected_subject.as_str()),
"admitted exact-2024 WebSocket must commit the static token subject into ctx.auth(): {admitted:?}"
);
assert_eq!(
handler_calls.snapshot().tool,
1,
"only the matching bearer token may invoke the authenticated handler"
);
websocket_client_bounded(&cx, "live exact-2024 auth close", client.close(&cx))
.await
.expect("the exact-2024 WebSocket auth client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn e2e_public_websocket_legacy_bind_filesystem_provider_lists_and_reads_live_file() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket filesystem runtime installs an ambient context",
);
let root = std::env::temp_dir().join(format!(
"fastmcp-public-ws-legacy-fs-e2e-{}",
std::process::id()
));
std::fs::create_dir_all(&root).expect("the exact-2024 WebSocket filesystem root is created");
std::fs::write(root.join(PUBLIC_HTTP_FS_FILE_NAME), PUBLIC_HTTP_FS_FILE_TEXT)
.expect("the exact-2024 WebSocket filesystem file is written");
let handler = providers::FilesystemProvider::new(&root)
.with_prefix(PUBLIC_HTTP_FS_PREFIX)
.with_exclude(&[])
.build()
.expect("FilesystemProvider::build must succeed for the live WebSocket proof");
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-fs", "1.0.0")
.resource(handler)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket filesystem must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket filesystem publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket filesystem serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 filesystem handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket filesystem must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 filesystem initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-fs", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates filesystem templates over bind_websocket");
let listed = websocket_client_bounded(
&cx,
"live exact-2024 resources/templates/list",
client.list_resource_templates(&cx),
)
.await
.expect("live exact-2024 bind_websocket must list the FilesystemProvider template");
assert!(
listed.iter().any(|template| template.uri_template == PUBLIC_HTTP_FS_TEMPLATE
&& template.name == PUBLIC_HTTP_FS_PREFIX),
"FilesystemProvider must advertise its reversible file template: {listed:?}"
);
let unmatched = websocket_client_bounded(
&cx,
"live exact-2024 unmatched filesystem resources/read",
client.read_resource(&cx, "file:///other/note.txt"),
)
.await
.expect_err("changing only the prefix the template cannot bind must refuse before dispatch");
assert_eq!(
unmatched.code,
McpErrorCode::ResourceNotFound,
"an unmatched filesystem URI must stay ResourceNotFound on exact-2024: {unmatched:?}"
);
let file = websocket_client_bounded(
&cx,
"live exact-2024 matched filesystem resources/read",
client.read_resource(&cx, PUBLIC_HTTP_FS_FILE_URI),
)
.await
.expect("resources/read must expand the live file URI through the filesystem handler");
let file_text = serde_json::to_value(&file)
.ok()
.and_then(|value| value["contents"][0]["text"].as_str().map(str::to_owned));
assert_eq!(
file_text.as_deref(),
Some(PUBLIC_HTTP_FS_FILE_TEXT),
"the live exact-2024 WebSocket filesystem read must retain the file bytes: {file:?}"
);
websocket_client_bounded(&cx, "live exact-2024 filesystem close", client.close(&cx))
.await
.expect("the exact-2024 filesystem WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_progress_marker_is_retained() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket progress runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-progress", "1.0.0")
.tool(PublicHttpProgressTool)
.resource(PublicHttpProgressResource)
.prompt(PublicHttpProgressPrompt)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket progress must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket progress publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket progress serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 progress handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket progress must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 progress initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-progress", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates progress over bind_websocket");
websocket_client_bounded(
&cx,
"live exact-2024 progress tools/call without token",
client.call_tool(&cx, PUBLIC_HTTP_PROGRESS_TOOL_NAME, json!({})),
)
.await
.expect("a tools/call without a progress token still completes");
let silent = client
.take_server_notifications()
.expect("exact-2024 WebSocket notifications without a token must decode");
assert!(
!silent.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"without a progressToken the exact-2024 WebSocket tool must stay silent: {silent:?}"
);
let marker = legacy_2024::ProgressMarker::from("ws-legacy-progress");
websocket_client_bounded(
&cx,
"live exact-2024 progress tools/call with token",
client.call_tool_with_progress_marker(
&cx,
PUBLIC_HTTP_PROGRESS_TOOL_NAME,
json!({}),
marker.clone(),
),
)
.await
.expect("a progressToken must not prevent the exact-2024 WebSocket tool from completing");
let progress = client
.take_server_notifications()
.expect("exact-2024 WebSocket progress notifications must decode");
assert!(
progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == marker
&& params.message.as_deref() == Some("halfway")
)),
"live exact-2024 WebSocket must retain notifications/progress after a progressToken: {progress:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 progress resources/read without token",
client.read_resource(&cx, PUBLIC_HTTP_PROGRESS_RESOURCE_URI),
)
.await
.expect("a resources/read without a progress token still completes");
let silent_resource = client
.take_server_notifications()
.expect("exact-2024 WebSocket resource notifications without a token must decode");
assert!(
!silent_resource.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"without a progressToken the exact-2024 WebSocket resource must stay silent: {silent_resource:?}"
);
let resource_marker = legacy_2024::ProgressMarker::from("ws-legacy-resource-progress");
websocket_client_bounded(
&cx,
"live exact-2024 progress resources/read with token",
client.read_resource_with_progress_marker(
&cx,
PUBLIC_HTTP_PROGRESS_RESOURCE_URI,
resource_marker.clone(),
),
)
.await
.expect("a progressToken must not prevent the exact-2024 WebSocket resource from completing");
let resource_progress = client
.take_server_notifications()
.expect("exact-2024 WebSocket resource progress notifications must decode");
assert!(
resource_progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == resource_marker
&& params.message.as_deref() == Some("resource-halfway")
)),
"live exact-2024 WebSocket must retain resource notifications/progress after a progressToken: {resource_progress:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 progress prompts/get without token",
client.get_prompt(&cx, PUBLIC_HTTP_PROGRESS_PROMPT_NAME, HashMap::new()),
)
.await
.expect("a prompts/get without a progress token still completes");
let silent_prompt = client
.take_server_notifications()
.expect("exact-2024 WebSocket prompt notifications without a token must decode");
assert!(
!silent_prompt.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"without a progressToken the exact-2024 WebSocket prompt must stay silent: {silent_prompt:?}"
);
let prompt_marker = legacy_2024::ProgressMarker::from("ws-legacy-prompt-progress");
websocket_client_bounded(
&cx,
"live exact-2024 progress prompts/get with token",
client.get_prompt_with_progress_marker(
&cx,
PUBLIC_HTTP_PROGRESS_PROMPT_NAME,
HashMap::new(),
prompt_marker.clone(),
),
)
.await
.expect("a progressToken must not prevent the exact-2024 WebSocket prompt from completing");
let prompt_progress = client
.take_server_notifications()
.expect("exact-2024 WebSocket prompt progress notifications must decode");
assert!(
prompt_progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == prompt_marker
&& params.message.as_deref() == Some("prompt-halfway")
)),
"live exact-2024 WebSocket must retain prompt notifications/progress after a progressToken: {prompt_progress:?}"
);
websocket_client_bounded(&cx, "live exact-2024 progress close", client.close(&cx))
.await
.expect("the exact-2024 progress WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_initialize_retains_instructions_and_peer_stays_bare() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket instructions runtime installs an ambient context",
);
let instructed =
legacy_2024::ServerBuilder::new("facade-ws-legacy-instructions", "1.0.0")
.instructions(PUBLIC_HTTP_INSTRUCTIONS)
.tool(PublicHttpValue)
.build();
let instructed_bound = instructed
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("instructed exact-2024 WebSocket must bind");
let instructed_address = instructed_bound
.local_addr()
.expect("instructed exact-2024 WebSocket publishes its address");
let bare =
legacy_2024::ServerBuilder::new("facade-ws-legacy-instructions-bare", "1.0.0")
.tool(PublicHttpValue)
.build();
let bare_bound = bare
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("bare exact-2024 WebSocket must bind");
let bare_address = bare_bound
.local_addr()
.expect("bare exact-2024 WebSocket publishes its address");
let scope = cx.scope();
let instructed_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
instructed_bound.serve(&serve_cx).await
})
.expect("instructed exact-2024 WebSocket serve must be admitted");
let bare_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bare_bound.serve(&serve_cx).await
})
.expect("bare exact-2024 WebSocket serve must be admitted");
let instructed_transport = websocket_client_bounded(
&cx,
"live exact-2024 instructed handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{instructed_address}/mcp")),
)
.await
.expect("instructed exact-2024 WebSocket must complete RFC 6455 upgrade");
let instructed_client = websocket_client_bounded(
&cx,
"live exact-2024 instructed initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-instructions", "1.0.0")
.connect_websocket_with_cx(&cx, instructed_transport),
)
.await
.expect("the instructed exact-2024 WebSocket peer must initialize");
assert_eq!(
instructed_client.instructions(),
Some(PUBLIC_HTTP_INSTRUCTIONS),
"live exact-2024 bind_websocket initialize must retain the configured instructions"
);
let bare_transport = websocket_client_bounded(
&cx,
"live exact-2024 bare handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{bare_address}/mcp")),
)
.await
.expect("bare exact-2024 WebSocket must complete RFC 6455 upgrade");
let bare_client = websocket_client_bounded(
&cx,
"live exact-2024 bare initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-instructions-bare", "1.0.0")
.connect_websocket_with_cx(&cx, bare_transport),
)
.await
.expect("the bare exact-2024 WebSocket peer must initialize");
assert_eq!(
bare_client.instructions(),
None,
"changing only the missing instructions must keep the peer bare"
);
drop(instructed_client);
drop(bare_client);
cx.set_cancel_requested(true);
instructed_listener.abort();
bare_listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_startup_hook_runs_and_peer_stays_unhooked() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket lifespan runtime installs an ambient context");
let hooked_startup = Arc::new(AtomicBool::new(false));
let bare_startup = Arc::new(AtomicBool::new(false));
let hooked = legacy_2024::ServerBuilder::new("facade-ws-legacy-lifespan", "1.0.0")
.tool(PublicHttpValue)
.on_startup({
let hooked_startup = Arc::clone(&hooked_startup);
move || -> Result<(), std::io::Error> {
hooked_startup.store(true, Ordering::SeqCst);
Ok(())
}
})
.build();
let hooked_bound = hooked
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("hooked exact-2024 WebSocket must bind");
let hooked_address = hooked_bound
.local_addr()
.expect("hooked exact-2024 WebSocket publishes its address");
let bare = legacy_2024::ServerBuilder::new("facade-ws-legacy-lifespan-bare", "1.0.0")
.tool(PublicHttpValue)
.build();
let bare_bound = bare
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("unhooked exact-2024 WebSocket must bind");
let bare_address = bare_bound
.local_addr()
.expect("unhooked exact-2024 WebSocket publishes its address");
let scope = cx.scope();
let hooked_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
hooked_bound.serve(&serve_cx).await
})
.expect("hooked exact-2024 WebSocket serve must be admitted");
let bare_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bare_bound.serve(&serve_cx).await
})
.expect("unhooked exact-2024 WebSocket serve must be admitted");
let hooked_transport = websocket_client_bounded(
&cx,
"live exact-2024 hooked handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{hooked_address}/mcp")),
)
.await
.expect("hooked exact-2024 WebSocket must complete RFC 6455 upgrade");
let mut hooked_client = websocket_client_bounded(
&cx,
"live exact-2024 hooked initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-lifespan", "1.0.0")
.connect_websocket_with_cx(&cx, hooked_transport),
)
.await
.expect("the hooked exact-2024 WebSocket peer must initialize");
let admitted = websocket_client_bounded(
&cx,
"live exact-2024 hooked tools/call",
hooked_client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect("tools/call must be admitted after the startup hook");
assert_eq!(
legacy_http_tool_text(&admitted).as_deref(),
Some("tool:alpha"),
"the hooked exact-2024 WebSocket peer must still return its text: {admitted:?}"
);
assert!(
hooked_startup.load(Ordering::SeqCst),
"LegacyOnly bind_websocket serve must run the public startup hook before admitting traffic"
);
assert!(
!bare_startup.load(Ordering::SeqCst),
"a peer without hooks must not invent lifespan events"
);
let bare_transport = websocket_client_bounded(
&cx,
"live exact-2024 unhooked handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{bare_address}/mcp")),
)
.await
.expect("unhooked exact-2024 WebSocket must complete RFC 6455 upgrade");
let mut bare_client = websocket_client_bounded(
&cx,
"live exact-2024 unhooked initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-lifespan-bare", "1.0.0")
.connect_websocket_with_cx(&cx, bare_transport),
)
.await
.expect("the unhooked exact-2024 WebSocket peer must initialize");
websocket_client_bounded(
&cx,
"live exact-2024 unhooked tools/call",
bare_client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect("the unhooked peer must still admit tools/call");
assert!(
!bare_startup.load(Ordering::SeqCst),
"omitting only the startup hook must keep the peer unhooked"
);
drop(hooked_client);
drop(bare_client);
cx.set_cancel_requested(true);
hooked_listener.abort();
bare_listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_session_state_is_retained_and_peer_stays_isolated() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket state runtime installs an ambient context");
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-state", "1.0.0")
.tool(PublicHttpStateTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket state must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket state publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket state serve must be admitted");
let writer_transport = websocket_client_bounded(
&cx,
"live exact-2024 state writer handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("writer exact-2024 WebSocket must complete RFC 6455 upgrade");
let mut writer = websocket_client_bounded(
&cx,
"live exact-2024 state writer initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-state", "1.0.0")
.connect_websocket_with_cx(&cx, writer_transport),
)
.await
.expect("the writer exact-2024 WebSocket peer must initialize");
let written = websocket_client_bounded(
&cx,
"live exact-2024 session state write",
writer.call_tool(
&cx,
PUBLIC_HTTP_STATE_TOOL_NAME,
json!({"action": "write", "value": "alpha"}),
),
)
.await
.expect("live exact-2024 WebSocket must write session state");
assert_eq!(
legacy_http_tool_text(&written).as_deref(),
Some("state:alpha"),
"set_state then get_state on the same WebSocket session must retain the value: {written:?}"
);
let later_read = websocket_client_bounded(
&cx,
"live exact-2024 session state later read",
writer.call_tool(&cx, PUBLIC_HTTP_STATE_TOOL_NAME, json!({"action": "read"})),
)
.await
.expect("a later exact-2024 WebSocket tools/call must still be admitted");
assert_eq!(
legacy_http_tool_text(&later_read).as_deref(),
Some("state:alpha"),
"the same WebSocket session must reuse the previous bag: {later_read:?}"
);
let peer_transport = websocket_client_bounded(
&cx,
"live exact-2024 state peer handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("peer exact-2024 WebSocket must complete RFC 6455 upgrade");
let mut peer = websocket_client_bounded(
&cx,
"live exact-2024 state peer initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-state-peer", "1.0.0")
.connect_websocket_with_cx(&cx, peer_transport),
)
.await
.expect("the peer exact-2024 WebSocket session must initialize");
let peer_read = websocket_client_bounded(
&cx,
"live exact-2024 session state peer read",
peer.call_tool(&cx, PUBLIC_HTTP_STATE_TOOL_NAME, json!({"action": "read"})),
)
.await
.expect("a peer WebSocket session must still be admitted");
assert_eq!(
legacy_http_tool_text(&peer_read).as_deref(),
Some("state:missing"),
"changing only the WebSocket session must not reuse the other session bag: {peer_read:?}"
);
drop(writer);
drop(peer);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_mask_error_details_hides_resource_execution_secret() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket mask runtime installs an ambient context");
let masked = legacy_2024::ServerBuilder::new("facade-ws-legacy-mask", "1.0.0")
.mask_error_details(true)
.resource(PublicHttpLeakResource)
.build();
let masked_bound = masked
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("masked exact-2024 WebSocket must bind");
let masked_address = masked_bound
.local_addr()
.expect("masked exact-2024 WebSocket publishes its address");
let unmasked = legacy_2024::ServerBuilder::new("facade-ws-legacy-unmask", "1.0.0")
.mask_error_details(false)
.resource(PublicHttpLeakResource)
.build();
let unmasked_bound = unmasked
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("unmasked exact-2024 WebSocket must bind");
let unmasked_address = unmasked_bound
.local_addr()
.expect("unmasked exact-2024 WebSocket publishes its address");
let scope = cx.scope();
let masked_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
masked_bound.serve(&serve_cx).await
})
.expect("masked exact-2024 WebSocket serve must be admitted");
let unmasked_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
unmasked_bound.serve(&serve_cx).await
})
.expect("unmasked exact-2024 WebSocket serve must be admitted");
let masked_transport = websocket_client_bounded(
&cx,
"live exact-2024 masked handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{masked_address}/mcp")),
)
.await
.expect("masked exact-2024 WebSocket must complete RFC 6455 upgrade");
let mut masked_client = websocket_client_bounded(
&cx,
"live exact-2024 masked initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-mask", "1.0.0")
.connect_websocket_with_cx(&cx, masked_transport),
)
.await
.expect("the masked exact-2024 WebSocket peer must initialize");
let masked = websocket_client_bounded(
&cx,
"live exact-2024 masked resources/read",
masked_client.read_resource(&cx, PUBLIC_HTTP_LEAK_RESOURCE_URI),
)
.await
.expect_err("a leaking resource must stay a resources/read error");
let masked = format!("{masked:?}");
assert!(
masked.contains("Internal server error"),
"exact-2024 WebSocket mask_error_details must replace the execution secret: {masked}"
);
assert!(
!masked.contains(PUBLIC_HTTP_LEAK_SECRET),
"exact-2024 WebSocket mask_error_details must not leak the execution secret: {masked}"
);
let unmasked_transport = websocket_client_bounded(
&cx,
"live exact-2024 unmasked handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{unmasked_address}/mcp")),
)
.await
.expect("unmasked exact-2024 WebSocket must complete RFC 6455 upgrade");
let mut unmasked_client = websocket_client_bounded(
&cx,
"live exact-2024 unmasked initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-unmask", "1.0.0")
.connect_websocket_with_cx(&cx, unmasked_transport),
)
.await
.expect("the unmasked exact-2024 WebSocket peer must initialize");
let unmasked = websocket_client_bounded(
&cx,
"live exact-2024 unmasked resources/read",
unmasked_client.read_resource(&cx, PUBLIC_HTTP_LEAK_RESOURCE_URI),
)
.await
.expect_err("changing only mask_error_details must still refuse the leaking resource");
let unmasked = format!("{unmasked:?}");
assert!(
unmasked.contains(PUBLIC_HTTP_LEAK_SECRET),
"disabling exact-2024 WebSocket mask_error_details must keep the execution secret: {unmasked}"
);
drop(masked_client);
drop(unmasked_client);
cx.set_cancel_requested(true);
masked_listener.abort();
unmasked_listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_mount_prefixes_tool_prompt_and_resource() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket mount runtime installs an ambient context");
let child = legacy_2024::ServerBuilder::new("facade-ws-legacy-mounted-child", "1.0.0")
.tool(PublicHttpValue)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.build();
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-mounted-parent", "1.0.0")
.mount(child, Some("child"))
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket mount must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket mount publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket mount serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 mount handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket mount must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 mount initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-mount", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates a mounted catalog over bind_websocket");
let listed = websocket_client_bounded(
&cx,
"live exact-2024 mounted tools/list",
client.list_tools(&cx),
)
.await
.expect("live exact-2024 WebSocket must list the prefixed child tool");
assert!(
listed
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_LEGACY_MOUNTED_TOOL_NAME),
"legacy mount() must rewrite the child tool name: {listed:?}"
);
assert!(
!listed.iter().any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME),
"a nonempty mount prefix must not keep the unprefixed child tool: {listed:?}"
);
let called = websocket_client_bounded(
&cx,
"live exact-2024 mounted tools/call",
client.call_tool(
&cx,
PUBLIC_HTTP_LEGACY_MOUNTED_TOOL_NAME,
json!({ "value": PUBLIC_HTTP_TOOL_ARGUMENT }),
),
)
.await
.expect("the prefixed child tool must dispatch");
assert_eq!(
legacy_http_tool_text(&called).as_deref(),
Some(PUBLIC_HTTP_TOOL_TEXT),
"the mounted live tool must retain the child handler value: {called:?}"
);
let listed_prompts = websocket_client_bounded(
&cx,
"live exact-2024 mounted prompts/list",
client.list_prompts(&cx),
)
.await
.expect("live exact-2024 WebSocket must list the prefixed child prompt");
assert!(
listed_prompts
.iter()
.any(|prompt| prompt.name == PUBLIC_HTTP_LEGACY_MOUNTED_PROMPT_NAME),
"legacy mount() must rewrite the child prompt name: {listed_prompts:?}"
);
let prompt = websocket_client_bounded(
&cx,
"live exact-2024 mounted prompts/get",
client.get_prompt(
&cx,
PUBLIC_HTTP_LEGACY_MOUNTED_PROMPT_NAME,
HashMap::from([(
"subject".to_owned(),
PUBLIC_HTTP_TOOL_ARGUMENT.to_owned(),
)]),
),
)
.await
.expect("the prefixed child prompt must dispatch");
let prompt = serde_json::to_value(prompt)
.expect("the mounted prompt result serializes for its observable assertion");
assert_eq!(
prompt["messages"][0]["content"]["text"],
json!(PUBLIC_HTTP_PROMPT_TEXT),
"the mounted live prompt must retain the child handler value: {prompt:?}"
);
let listed_resources = websocket_client_bounded(
&cx,
"live exact-2024 mounted resources/list",
client.list_resources(&cx),
)
.await
.expect("live exact-2024 WebSocket must list the prefixed child resource");
assert!(
listed_resources
.iter()
.any(|resource| resource.uri == PUBLIC_HTTP_LEGACY_MOUNTED_RESOURCE_URI),
"legacy mount() must prefix every resource key: {listed_resources:?}"
);
assert!(
!listed_resources
.iter()
.any(|resource| resource.uri == PUBLIC_HTTP_RESOURCE_URI),
"legacy mount() must not keep the unprefixed child resource URI: {listed_resources:?}"
);
let resource = websocket_client_bounded(
&cx,
"live exact-2024 mounted resources/read",
client.read_resource(&cx, PUBLIC_HTTP_LEGACY_MOUNTED_RESOURCE_URI),
)
.await
.expect("the prefixed child resource must dispatch");
let resource_text = serde_json::to_value(&resource)
.ok()
.and_then(|value| value["contents"][0]["text"].as_str().map(str::to_owned));
assert_eq!(
resource_text.as_deref(),
Some(PUBLIC_HTTP_RESOURCE_TEXT),
"the mounted live resource must retain the child handler value: {resource:?}"
);
websocket_client_bounded(&cx, "live exact-2024 mount close", client.close(&cx))
.await
.expect("the exact-2024 mount WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_bind_as_proxy_prefixes_tool_prompt_and_resource() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket as_proxy runtime installs an ambient context");
let upstream = spawn_legacy_upstream_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-gateway".to_owned(),
"e2e-legacy-ws-proxy-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-live-upstream",
"native-h1:e2e-legacy-ws-live-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live exact-2024 HTTP proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP proxy catalog is typed");
let ProxyToolCatalog::Legacy(tools) = &catalog.tools else {
panic!("exact-2024 WebSocket as_proxy catalog must stay on the 2024 tool model");
};
assert!(
tools.iter().any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME),
"live exact-2024 HTTP proxy catalog omitted {PUBLIC_HTTP_TOOL_NAME}: {tools:?}"
);
let server = legacy_2024::ServerBuilder::new("e2e-legacy-ws-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket as_proxy publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket as_proxy serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket as_proxy must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates as_proxy over bind_websocket");
let listed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy tools/list",
client.list_tools(&cx),
)
.await
.expect("live exact-2024 WebSocket as_proxy must list the prefixed upstream tool");
assert!(
listed
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_LEGACY_PROXY_TOOL_NAME),
"legacy as_proxy_typed must rewrite the upstream tool name: {listed:?}"
);
assert!(
!listed.iter().any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME),
"a nonempty as_proxy prefix must not keep the unprefixed upstream tool: {listed:?}"
);
let called = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy tools/call",
client.call_tool(
&cx,
PUBLIC_HTTP_LEGACY_PROXY_TOOL_NAME,
json!({ "value": PUBLIC_HTTP_TOOL_ARGUMENT }),
),
)
.await
.expect("the prefixed upstream tool must dispatch through the live as_proxy gateway");
assert_eq!(
legacy_http_tool_text(&called).as_deref(),
Some(PUBLIC_HTTP_TOOL_TEXT),
"the proxied live tool must retain the upstream handler value: {called:?}"
);
let listed_prompts = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy prompts/list",
client.list_prompts(&cx),
)
.await
.expect("live exact-2024 WebSocket as_proxy must list the prefixed upstream prompt");
assert!(
listed_prompts
.iter()
.any(|prompt| prompt.name == PUBLIC_HTTP_LEGACY_PROXY_PROMPT_NAME),
"legacy as_proxy_typed must rewrite the upstream prompt name: {listed_prompts:?}"
);
let prompt = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy prompts/get",
client.get_prompt(
&cx,
PUBLIC_HTTP_LEGACY_PROXY_PROMPT_NAME,
HashMap::from([(
"subject".to_owned(),
PUBLIC_HTTP_TOOL_ARGUMENT.to_owned(),
)]),
),
)
.await
.expect("the prefixed upstream prompt must dispatch through the live as_proxy gateway");
let prompt = serde_json::to_value(prompt)
.expect("the proxied prompt result serializes for its observable assertion");
assert_eq!(
prompt["messages"][0]["content"]["text"],
json!(PUBLIC_HTTP_PROMPT_TEXT),
"the proxied live prompt must retain the upstream handler value: {prompt:?}"
);
let listed_resources = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy resources/list",
client.list_resources(&cx),
)
.await
.expect("live exact-2024 WebSocket as_proxy must list the prefixed upstream resource");
assert!(
listed_resources
.iter()
.any(|resource| resource.uri == PUBLIC_HTTP_LEGACY_PROXY_RESOURCE_URI),
"legacy as_proxy_typed must prefix every resource key: {listed_resources:?}"
);
assert!(
!listed_resources
.iter()
.any(|resource| resource.uri == PUBLIC_HTTP_RESOURCE_URI),
"legacy as_proxy_typed must not keep the unprefixed upstream resource URI: {listed_resources:?}"
);
let resource = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy resources/read",
client.read_resource(&cx, PUBLIC_HTTP_LEGACY_PROXY_RESOURCE_URI),
)
.await
.expect("the prefixed upstream resource must dispatch through the live as_proxy gateway");
let resource_text = serde_json::to_value(&resource)
.ok()
.and_then(|value| value["contents"][0]["text"].as_str().map(str::to_owned));
assert_eq!(
resource_text.as_deref(),
Some(PUBLIC_HTTP_RESOURCE_TEXT),
"the proxied live resource must retain the upstream handler value: {resource:?}"
);
websocket_client_bounded(&cx, "live exact-2024 as_proxy close", client.close(&cx))
.await
.expect("the exact-2024 as_proxy WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_forwards_resource_template_completion() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy template-completion runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_template_completion_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-template-complete-gateway".to_owned(),
"e2e-legacy-ws-proxy-template-complete-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket template-completion gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-template-complete-upstream",
"native-h1:e2e-legacy-ws-template-complete-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-template-complete".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live exact-2024 HTTP template-completion proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP template-completion proxy catalog is typed");
let server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-template-complete-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed template-completion install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy template-completion must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy template-completion publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy template-completion serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy template-completion handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket template-completion must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy template-completion initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-template-complete", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy template-completion over bind_websocket",
);
let completed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy template complete",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Resource {
uri: format!("child/{PUBLIC_HTTP_CURSOR_TEMPLATE}"),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "id".to_owned(),
value: "al".to_owned(),
},
meta: None,
},
),
)
.await
.expect("exact-2024 as_proxy WebSocket must rewrite the prefixed template URI");
assert_eq!(
completed.completion.values,
vec![PUBLIC_HTTP_TEMPLATE_COMPLETION_LEGACY_VALUE.to_owned()],
"exact-2024 as_proxy WebSocket must retain the upstream template completion: {completed:?}"
);
let missing = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy unprefixed template complete",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Resource {
uri: PUBLIC_HTTP_CURSOR_TEMPLATE.to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "id".to_owned(),
value: "al".to_owned(),
},
meta: None,
},
),
)
.await
.expect_err("changing only the prefix must not reach the exact-2024 upstream provider");
let _ = missing;
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy template-completion close",
client.close(&cx),
)
.await
.expect("the exact-2024 as_proxy WebSocket template-completion client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_advertises_completions_when_upstream_supports() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy completion-advertise runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_template_completion_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-complete-advertise-gateway".to_owned(),
"e2e-legacy-ws-proxy-complete-advertise-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket completion-advertise gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-complete-advertise-upstream",
"native-h1:e2e-legacy-ws-complete-advertise-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-complete-advertise".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP template-completion proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP template-completion proxy catalog is typed");
let server = legacy_2024::ServerBuilder::new(
"e2e-legacy-ws-complete-advertise-gateway",
"1.0.0",
)
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed completion-advertise install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect(
"public LegacyOnly bind_websocket as_proxy completion-advertise must bind",
);
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy completion-advertise publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy completion-advertise serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy completion-advertise handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect(
"exact-2024 as_proxy WebSocket completion-advertise must complete RFC 6455 upgrade",
);
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy completion-advertise initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-complete-advertise", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy completion-advertise over bind_websocket",
);
assert!(
client.server_capabilities().completions.is_some(),
"exact-2024 as_proxy WebSocket must advertise initialize completions when the upstream installed a provider: {:?}",
client.server_capabilities()
);
let completed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy advertised complete",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Resource {
uri: format!("child/{PUBLIC_HTTP_CURSOR_TEMPLATE}"),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "id".to_owned(),
value: "al".to_owned(),
},
meta: None,
},
),
)
.await
.expect(
"advertised exact-2024 as_proxy WebSocket completions must still forward the prefixed template",
);
assert_eq!(
completed.completion.values,
vec![PUBLIC_HTTP_TEMPLATE_COMPLETION_LEGACY_VALUE.to_owned()],
"exact-2024 as_proxy WebSocket must retain the upstream template completion after advertising: {completed:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy completion-advertise close",
client.close(&cx),
)
.await
.expect(
"the exact-2024 as_proxy WebSocket completion-advertise client closes after the live proof",
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_omits_completions_when_upstream_has_none() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy completion-omit runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_counting_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-complete-omit-gateway".to_owned(),
"e2e-legacy-ws-proxy-complete-omit-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket completion-omit gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-complete-omit-upstream",
"native-h1:e2e-legacy-ws-complete-omit-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-complete-omit".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP counting proxy catalog is typed");
let server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-complete-omit-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed completion-omit install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy completion-omit must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy completion-omit publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy completion-omit serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy completion-omit handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket completion-omit must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy completion-omit initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-complete-omit", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy completion-omit over bind_websocket",
);
assert!(
client.server_capabilities().completions.is_none(),
"changing only the missing upstream completion provider must omit exact-2024 as_proxy WebSocket completions: {:?}",
client.server_capabilities()
);
let refused = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy omitted complete",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt {
name: format!("child/{PUBLIC_HTTP_PROMPT_NAME}"),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "subject".to_owned(),
value: "al".to_owned(),
},
meta: None,
},
),
)
.await
.expect_err(
"an exact-2024 as_proxy WebSocket gateway without a completion provider must refuse complete",
);
let _ = refused;
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy completion-omit close",
client.close(&cx),
)
.await
.expect(
"the exact-2024 as_proxy WebSocket completion-omit client closes after the live proof",
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(all(feature = "proxy", any(target_os = "linux", target_os = "macos")))]
#[test]
fn e2e_public_websocket_legacy_as_proxy_forwards_filesystem_template() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy filesystem runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_filesystem_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-filesystem-gateway".to_owned(),
"e2e-legacy-ws-proxy-filesystem-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket filesystem gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-filesystem-upstream",
"native-h1:e2e-legacy-ws-filesystem-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-filesystem".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP filesystem proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP filesystem proxy catalog is typed");
let server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-filesystem-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed filesystem install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy filesystem must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy filesystem publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy filesystem serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy filesystem handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket filesystem must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy filesystem initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-filesystem", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy filesystem over bind_websocket",
);
let listed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy filesystem resources/templates/list",
client.list_resource_templates(&cx),
)
.await
.expect("exact-2024 as_proxy WebSocket must list the prefixed filesystem template");
let prefixed = format!("child/{PUBLIC_HTTP_FS_TEMPLATE}");
assert!(
listed
.iter()
.any(|template| template.uri_template == prefixed
&& template.name == PUBLIC_HTTP_FS_PREFIX),
"exact-2024 as_proxy WebSocket must prefix the filesystem template: {listed:?}"
);
assert!(
listed
.iter()
.all(|template| template.uri_template != PUBLIC_HTTP_FS_TEMPLATE),
"a nonempty as_proxy prefix must not keep the unprefixed filesystem template: {listed:?}"
);
let unmatched = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy unmatched filesystem resources/read",
client.read_resource(&cx, &format!("child/file:///other/{PUBLIC_HTTP_FS_FILE_NAME}")),
)
.await
.expect_err(
"changing only the filesystem prefix must refuse before the upstream handler",
);
let _ = unmatched;
let unprefixed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy unprefixed filesystem resources/read",
client.read_resource(&cx, PUBLIC_HTTP_FS_FILE_URI),
)
.await
.expect_err(
"changing only the as_proxy prefix must not reach the upstream filesystem",
);
let _ = unprefixed;
let file = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy matched filesystem resources/read",
client.read_resource(&cx, &format!("child/{PUBLIC_HTTP_FS_FILE_URI}")),
)
.await
.expect(
"exact-2024 as_proxy WebSocket must rewrite the prefixed file URI onto the upstream provider",
);
let file_text = serde_json::to_value(&file)
.ok()
.and_then(|value| value["contents"][0]["text"].as_str().map(str::to_owned));
assert_eq!(
file_text.as_deref(),
Some(PUBLIC_HTTP_FS_FILE_TEXT),
"exact-2024 as_proxy WebSocket must retain the upstream filesystem bytes: {file:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy filesystem close",
client.close(&cx),
)
.await
.expect("the exact-2024 as_proxy WebSocket filesystem client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_forwards_resource_updated_on_subscribe() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy subscribe runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_subscription_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-subscribe-gateway".to_owned(),
"e2e-legacy-ws-proxy-subscribe-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket subscribe gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-subscribe-upstream",
"native-h1:e2e-legacy-ws-subscribe-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-subscribe".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP subscribe proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP subscribe proxy catalog is typed");
let server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-subscribe-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed subscribe install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy subscribe must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy subscribe publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy subscribe serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy subscribe handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket subscribe must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy subscribe initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-subscribe", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy subscribe over bind_websocket",
);
let prefixed_resource = format!("child/{PUBLIC_HTTP_WATCH_RESOURCE_URI}");
let prefixed_touch = format!("child/{PUBLIC_HTTP_TOUCH_TOOL_NAME}");
let silent = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy touch without subscribe",
client.call_tool(&cx, &prefixed_touch, json!({})),
)
.await
.expect("prefixed touch must complete without a gateway subscribe");
assert_eq!(
legacy_http_tool_text(&silent).as_deref(),
Some("silent"),
"omitting only subscribe must keep upstream notify_resource_updated silent: {silent:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy missing resources/subscribe",
client.subscribe_resource(
&cx,
&format!("child/{PUBLIC_HTTP_WATCH_RESOURCE_URI}-missing"),
),
)
.await
.expect_err(
"changing only the subscribed URI must refuse before the upstream subscribe",
);
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy resources/subscribe",
client.subscribe_resource(&cx, &prefixed_resource),
)
.await
.expect("exact-2024 as_proxy WebSocket must admit subscribe of the prefixed resource");
let touched = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy touch after subscribe",
client.call_tool(&cx, &prefixed_touch, json!({})),
)
.await
.expect("prefixed touch after subscribe must complete");
assert_eq!(
legacy_http_tool_text(&touched).as_deref(),
Some("notified"),
"as_proxy WebSocket subscribe must count as upstream notify_resource_updated delivery: {touched:?}"
);
let updated = client
.take_server_notifications()
.expect("exact-2024 as_proxy WebSocket notifications after matching notify must decode");
assert!(
updated.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ResourceUpdated(params)
if params.uri == prefixed_resource
)),
"exact-2024 as_proxy WebSocket must retain prefixed resources/updated after subscribe: {updated:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy resources/unsubscribe",
client.unsubscribe_resource(&cx, &prefixed_resource),
)
.await
.expect(
"exact-2024 as_proxy WebSocket must admit unsubscribe of the prefixed resource",
);
let after_unsub = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy touch after unsubscribe",
client.call_tool(&cx, &prefixed_touch, json!({})),
)
.await
.expect("prefixed touch after unsubscribe must complete");
assert_eq!(
legacy_http_tool_text(&after_unsub).as_deref(),
Some("silent"),
"as_proxy WebSocket unsubscribe must keep later upstream notify_resource_updated silent: {after_unsub:?}"
);
let later = client
.take_server_notifications()
.expect("exact-2024 as_proxy WebSocket notifications after unsubscribe must decode");
assert!(
!later.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::ResourceUpdated(_)
)),
"exact-2024 as_proxy WebSocket must not retain resources/updated after unsubscribe: {later:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy subscribe close",
client.close(&cx),
)
.await
.expect("the exact-2024 as_proxy WebSocket subscribe client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_forwards_inbound_log_level() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy log runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_subscription_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-log-gateway".to_owned(),
"e2e-legacy-ws-proxy-log-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket log gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-log-upstream",
"native-h1:e2e-legacy-ws-log-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-log".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP log proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP log proxy catalog is typed");
let server = legacy_2024::ServerBuilder::new("e2e-legacy-ws-log-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed log install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy log must bind");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket as_proxy log publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket as_proxy log serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy log handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket log must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy log initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-log", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates as_proxy log over bind_websocket");
let prefixed = format!("child/{PUBLIC_HTTP_LOG_TOOL_NAME}");
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy log before setLevel",
client.call_tool(&cx, &prefixed, json!({})),
)
.await
.expect("prefixed log tool must complete without setLevel");
let silent = client
.take_server_notifications()
.expect("exact-2024 as_proxy WebSocket notifications before setLevel must decode");
assert!(
!silent.iter().any(|notification| legacy_http_log_message_is(
notification,
PUBLIC_HTTP_HANDLER_LOG_TEXT
)),
"omitting only setLevel must keep as_proxy WebSocket ctx.info silent: {silent:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy logging/setLevel Info",
client.set_log_level(&cx, legacy_2024::LogLevel::Info),
)
.await
.expect("exact-2024 as_proxy WebSocket logging/setLevel must be admitted");
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy log after setLevel Info",
client.call_tool(&cx, &prefixed, json!({})),
)
.await
.expect("prefixed log tool must complete after setLevel Info");
let info = client
.take_server_notifications()
.expect("exact-2024 as_proxy WebSocket notifications after setLevel Info must decode");
assert!(
info.iter().any(|notification| legacy_http_log_message_is(
notification,
PUBLIC_HTTP_HANDLER_LOG_TEXT
)),
"exact-2024 as_proxy WebSocket must retain upstream ctx.info after set_log_level(Info): {info:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy logging/setLevel Emergency",
client.set_log_level(&cx, legacy_2024::LogLevel::Emergency),
)
.await
.expect("raising the as_proxy WebSocket log floor must be admitted");
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy log after setLevel Emergency",
client.call_tool(&cx, &prefixed, json!({})),
)
.await
.expect("raising the log floor must not prevent the prefixed log tool");
let emergency = client
.take_server_notifications()
.expect("exact-2024 as_proxy WebSocket notifications after Emergency must decode");
assert!(
!emergency
.iter()
.any(|notification| legacy_http_log_message_is(
notification,
PUBLIC_HTTP_HANDLER_LOG_TEXT
)),
"raising only the inbound log floor must suppress as_proxy WebSocket ctx.info: {emergency:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy log close",
client.close(&cx),
)
.await
.expect("the exact-2024 as_proxy WebSocket log client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_forwards_inbound_progress_marker() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy progress runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_subscription_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-progress-gateway".to_owned(),
"e2e-legacy-ws-proxy-progress-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket progress gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-progress-upstream",
"native-h1:e2e-legacy-ws-progress-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-progress".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP progress proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP progress proxy catalog is typed");
let server = legacy_2024::ServerBuilder::new("e2e-legacy-ws-progress-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed progress install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy progress must bind");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket as_proxy progress publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket as_proxy progress serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy progress handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket progress must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy progress initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-progress", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates as_proxy progress over bind_websocket");
let prefixed_tool = format!("child/{PUBLIC_HTTP_PROGRESS_TOOL_NAME}");
let prefixed_resource = format!("child/{PUBLIC_HTTP_PROGRESS_RESOURCE_URI}");
let prefixed_prompt = format!("child/{PUBLIC_HTTP_PROGRESS_PROMPT_NAME}");
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy progress tools/call before token",
client.call_tool(&cx, &prefixed_tool, json!({})),
)
.await
.expect("prefixed progress tool must complete without a progressToken");
let silent = client
.take_server_notifications()
.expect("exact-2024 as_proxy WebSocket notifications before token must decode");
assert!(
!silent.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"omitting only the progressToken must keep as_proxy WebSocket tools/call silent: {silent:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy progress unprefixed tools/call",
client.call_tool_with_progress_marker(
&cx,
PUBLIC_HTTP_PROGRESS_TOOL_NAME,
json!({}),
legacy_2024::ProgressMarker::from("ws-legacy-as-proxy-progress-unprefixed"),
),
)
.await
.expect_err("changing only the tool name must refuse before the unprefixed progress handler");
let marker = legacy_2024::ProgressMarker::from("ws-legacy-as-proxy-progress");
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy progress tools/call with token",
client.call_tool_with_progress_marker(&cx, &prefixed_tool, json!({}), marker.clone()),
)
.await
.expect("prefixed progress tool must complete after a progressToken");
let progress = client
.take_server_notifications()
.expect("exact-2024 as_proxy WebSocket notifications after token must decode");
assert!(
progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == marker
&& params.message.as_deref() == Some("halfway")
)),
"exact-2024 as_proxy WebSocket must retain upstream tools/call notifications/progress: {progress:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy progress resources/read before token",
client.read_resource(&cx, &prefixed_resource),
)
.await
.expect("prefixed progress resource must complete without a progressToken");
let silent_resource = client
.take_server_notifications()
.expect("exact-2024 as_proxy WebSocket resource notifications before token must decode");
assert!(
!silent_resource.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"omitting only the progressToken must keep as_proxy WebSocket resources/read silent: {silent_resource:?}"
);
let resource_marker =
legacy_2024::ProgressMarker::from("ws-legacy-as-proxy-resource-progress");
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy progress resources/read with token",
client.read_resource_with_progress_marker(
&cx,
&prefixed_resource,
resource_marker.clone(),
),
)
.await
.expect("prefixed progress resource must complete after a progressToken");
let resource_progress = client
.take_server_notifications()
.expect("exact-2024 as_proxy WebSocket resource notifications after token must decode");
assert!(
resource_progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == resource_marker
&& params.message.as_deref() == Some("resource-halfway")
)),
"exact-2024 as_proxy WebSocket must retain upstream resources/read notifications/progress: {resource_progress:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy progress prompts/get before token",
client.get_prompt(&cx, &prefixed_prompt, HashMap::new()),
)
.await
.expect("prefixed progress prompt must complete without a progressToken");
let silent_prompt = client
.take_server_notifications()
.expect("exact-2024 as_proxy WebSocket prompt notifications before token must decode");
assert!(
!silent_prompt.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"omitting only the progressToken must keep as_proxy WebSocket prompts/get silent: {silent_prompt:?}"
);
let prompt_marker =
legacy_2024::ProgressMarker::from("ws-legacy-as-proxy-prompt-progress");
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy progress prompts/get with token",
client.get_prompt_with_progress_marker(
&cx,
&prefixed_prompt,
HashMap::new(),
prompt_marker.clone(),
),
)
.await
.expect("prefixed progress prompt must complete after a progressToken");
let prompt_progress = client
.take_server_notifications()
.expect("exact-2024 as_proxy WebSocket prompt notifications after token must decode");
assert!(
prompt_progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == prompt_marker
&& params.message.as_deref() == Some("prompt-halfway")
)),
"exact-2024 as_proxy WebSocket must retain upstream prompts/get notifications/progress: {prompt_progress:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy progress close",
client.close(&cx),
)
.await
.expect("the exact-2024 as_proxy WebSocket progress client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_forwards_inbound_sampling_and_roots() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy sampling runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_subscription_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-sample-gateway".to_owned(),
"e2e-legacy-ws-proxy-sample-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket sampling gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-sample-upstream",
"native-h1:e2e-legacy-ws-sample-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-sample".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities {
sampling: Some(Default::default()),
elicitation: None,
roots: Some(legacy_2024::RootsCapability {
list_changed: false,
}),
},
cx.clone(),
)
.expect(
"live exact-2024 HTTP sampling proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP sampling proxy catalog is typed");
let server = legacy_2024::ServerBuilder::new("e2e-legacy-ws-sample-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed sampling install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy sampling must bind");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket as_proxy sampling publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket as_proxy sampling serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy sampling handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket sampling must complete RFC 6455 upgrade");
let sample_calls = Arc::new(AtomicUsize::new(0));
let roots_calls = Arc::new(AtomicUsize::new(0));
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy sampling initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-sample", "1.0.0")
.capabilities(ClientCapabilities {
sampling: Some(Default::default()),
elicitation: None,
roots: Some(Default::default()),
})
.reverse_request_handlers(
legacy_2024::LegacyReverseRequestHandlers::new()
.with_sampling_create_message({
let sample_calls = Arc::clone(&sample_calls);
move |_cx, _cancel, _params| {
sample_calls.fetch_add(1, Ordering::Relaxed);
Box::pin(async {
Ok(legacy_2024::LegacyCreateMessageResult::text(
PUBLIC_HTTP_SAMPLED_TEXT,
"e2e-legacy-ws-as-proxy-model",
))
})
}
})
.with_roots_list({
let roots_calls = Arc::clone(&roots_calls);
move |_cx, _cancel, _params| {
roots_calls.fetch_add(1, Ordering::Relaxed);
Box::pin(async {
Ok(legacy_2024::ListRootsResult::new(vec![
legacy_2024::Root::with_name(
PUBLIC_HTTP_LEGACY_ROOT_URI,
"workspace",
),
]))
})
}
}),
)
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the inbound sampling and roots callbacks are configured before initialize");
let prefixed_sample = format!("child/{PUBLIC_HTTP_SAMPLE_TOOL_NAME}");
let prefixed_roots = format!("child/{PUBLIC_HTTP_LEGACY_ROOTS_TOOL_NAME}");
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy unprefixed sampling tools/call",
client.call_tool(&cx, PUBLIC_HTTP_SAMPLE_TOOL_NAME, json!({})),
)
.await
.expect_err("changing only the tool name must refuse before the unprefixed sampling handler");
let sampled = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy sampling tools/call",
client.call_tool(&cx, &prefixed_sample, json!({})),
)
.await
.expect("exact-2024 as_proxy WebSocket must forward sampling/createMessage inbound");
assert_eq!(
legacy_http_tool_text(&sampled).as_deref(),
Some(PUBLIC_HTTP_SAMPLED_TEXT),
"exact-2024 as_proxy WebSocket must retain the inbound sampling callback text: {sampled:?}"
);
assert_eq!(
sample_calls.load(Ordering::Relaxed),
1,
"the upstream sample tool must issue exactly one inbound sampling/createMessage"
);
let rooted = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy roots tools/call",
client.call_tool(&cx, &prefixed_roots, json!({})),
)
.await
.expect("exact-2024 as_proxy WebSocket must forward roots/list inbound");
assert_eq!(
legacy_http_tool_text(&rooted).as_deref(),
Some(PUBLIC_HTTP_LEGACY_ROOT_URI),
"exact-2024 as_proxy WebSocket must retain the inbound roots callback URI: {rooted:?}"
);
assert_eq!(
roots_calls.load(Ordering::Relaxed),
1,
"the upstream roots tool must issue exactly one inbound roots/list"
);
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy sampling close",
client.close(&cx),
)
.await
.expect("the exact-2024 as_proxy WebSocket sampling client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_adopts_upstream_instructions() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy instructions runtime installs an ambient context",
);
let instructed_upstream = spawn_legacy_upstream_http_server_with_instructions();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(instructed_upstream.address(), "/sse")),
Some(public_http_target(instructed_upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-instructions-gateway".to_owned(),
"e2e-legacy-ws-proxy-instructions-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket instructions gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-instructions-upstream",
"native-h1:e2e-legacy-ws-instructions-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-instructions".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP instructions proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP instructions proxy catalog is typed");
let server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-instructions-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed instructions install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy instructions must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy instructions publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy instructions serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy instructions handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket instructions must complete RFC 6455 upgrade");
let client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy instructions initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-instructions", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates as_proxy instructions over bind_websocket");
assert_eq!(
client.instructions(),
Some(PUBLIC_HTTP_INSTRUCTIONS),
"exact-2024 WebSocket as_proxy must adopt the upstream initialize instructions"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
instructed_upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_bare_upstream_omits_instructions() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy bare-instructions runtime installs an ambient context",
);
let bare_upstream = spawn_legacy_upstream_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(bare_upstream.address(), "/sse")),
Some(public_http_target(bare_upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-bare-instructions-gateway".to_owned(),
"e2e-legacy-ws-proxy-bare-instructions-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket bare-instructions gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-bare-instructions-upstream",
"native-h1:e2e-legacy-ws-bare-instructions-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-bare-instructions".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP bare-instructions proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP bare-instructions proxy catalog is typed");
let server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-instructions-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed bare-instructions install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy bare-instructions must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy bare-instructions publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy bare-instructions serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy bare-instructions handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect(
"exact-2024 as_proxy WebSocket bare-instructions must complete RFC 6455 upgrade",
);
let client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy bare-instructions initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-instructions-bare", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates as_proxy bare-instructions over bind_websocket");
assert_eq!(
client.instructions(),
None,
"changing only the missing upstream instructions must keep the exact-2024 as_proxy WebSocket gateway bare"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
bare_upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_forwards_hide_and_show_of_prefixed_tool() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy hide runtime installs an ambient context",
);
let hide_upstream = spawn_legacy_hide_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(hide_upstream.address(), "/sse")),
Some(public_http_target(hide_upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-hide-gateway".to_owned(),
"e2e-legacy-ws-proxy-hide-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket hide gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-hide-upstream",
"native-h1:e2e-legacy-ws-hide-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-hide".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP hide proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP hide proxy catalog is typed");
let server = legacy_2024::ServerBuilder::new("e2e-legacy-ws-hide-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed hide install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy hide must bind");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket as_proxy hide publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket as_proxy hide serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket hide must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-hide", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates as_proxy hide over bind_websocket");
let prefixed_hide = format!("child/{PUBLIC_HTTP_HIDE_TOOL_NAME}");
let prefixed_show = format!("child/{PUBLIC_HTTP_SHOW_TOOL_NAME}");
let prefixed_touch = format!("child/{PUBLIC_HTTP_TOUCH_TOOL_NAME}");
let prefixed_fast = format!("child/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let listed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide list_tools",
client.list_tools(&cx),
)
.await
.expect("the live exact-2024 as_proxy WebSocket gateway must advertise the prefixed hide catalog");
assert!(
listed.iter().any(|tool| tool.name == prefixed_hide)
&& listed.iter().any(|tool| tool.name == prefixed_touch)
&& listed.iter().any(|tool| tool.name == prefixed_fast),
"legacy as_proxy_typed must prefix the live hide/touch/fast tools over WebSocket: {listed:?}"
);
let before = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide touch before",
client.call_tool(&cx, &prefixed_touch, json!({})),
)
.await
.expect("as_proxy WebSocket must forward the prefixed touch tool before hide");
assert_eq!(
legacy_http_tool_text(&before).as_deref(),
Some("silent"),
"changing only the missing hide must keep prefixed touch admitted: {before:?}"
);
let missing = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide unprefixed",
client.call_tool(&cx, PUBLIC_HTTP_HIDE_TOOL_NAME, json!({})),
)
.await
.expect_err("changing only the tool name must not reach the unprefixed hide handler");
let _ = missing;
let hidden = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide tools/call",
client.call_tool(&cx, &prefixed_hide, json!({})),
)
.await
.expect("as_proxy WebSocket must forward the prefixed hide tool");
assert_eq!(
legacy_http_tool_text(&hidden).as_deref(),
Some("hidden"),
"as_proxy WebSocket must retain the upstream disable_tool mutation: {hidden:?}"
);
let still_listed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide list after",
client.list_tools(&cx),
)
.await
.expect("the live exact-2024 as_proxy WebSocket gateway must keep its install-time catalog after hide");
assert!(
still_listed
.iter()
.any(|tool| tool.name == prefixed_touch),
"as_proxy WebSocket must not drop the prefixed touch tool from the gateway snapshot: {still_listed:?}"
);
let disabled = legacy_disabled_tool_observable(
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide touch after",
client.call_tool(&cx, &prefixed_touch, json!({})),
)
.await,
);
assert!(
disabled.contains("disabled")
|| disabled.contains(PUBLIC_HTTP_TOUCH_TOOL_NAME)
|| disabled.contains("Method not found")
|| disabled.contains("MethodNotFound"),
"hide on the live exact-2024 WebSocket session must refuse a later prefixed touch: {disabled}"
);
let peer = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide peer",
client.call_tool(&cx, &prefixed_fast, json!({})),
)
.await
.expect("changing only the tool name must still reach an undisabled HTTP handler");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("fast"),
"as_proxy WebSocket must keep undisabled HTTP tools callable after hide: {peer:?}"
);
let shown = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy show tools/call",
client.call_tool(&cx, &prefixed_show, json!({})),
)
.await
.expect("as_proxy WebSocket must forward the prefixed show tool");
assert_eq!(
legacy_http_tool_text(&shown).as_deref(),
Some("shown"),
"as_proxy WebSocket must retain the upstream enable_tool mutation: {shown:?}"
);
let restored = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide touch restored",
client.call_tool(&cx, &prefixed_touch, json!({})),
)
.await
.expect("show on the live exact-2024 WebSocket session must restore the prefixed touch tool");
assert_eq!(
legacy_http_tool_text(&restored).as_deref(),
Some("silent"),
"as_proxy WebSocket must keep the restored HTTP touch tool callable: {restored:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
hide_upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_composes_nested_tool_and_resource() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy compose runtime installs an ambient context",
);
let compose_upstream = spawn_legacy_compose_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(compose_upstream.address(), "/sse")),
Some(public_http_target(compose_upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-compose-gateway".to_owned(),
"e2e-legacy-ws-proxy-compose-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket compose gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-compose-upstream",
"native-h1:e2e-legacy-ws-compose-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-compose".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP compose proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP compose proxy catalog is typed");
let server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-compose-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed compose install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy compose must bind");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket as_proxy compose publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket as_proxy compose serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy compose handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket compose must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy compose initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-compose", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates as_proxy compose over bind_websocket");
let prefixed = format!("child/{PUBLIC_HTTP_COMPOSE_TOOL_NAME}");
let missing = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy compose unprefixed",
client.call_tool(
&cx,
PUBLIC_HTTP_COMPOSE_TOOL_NAME,
json!({"value": "alpha"}),
),
)
.await
.expect_err("changing only the tool name must not reach the unprefixed compose handler");
let _ = missing;
let composed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy compose tools/call",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("as_proxy WebSocket must forward prefixed compose onto the live exact-2024 HTTP upstream");
assert_eq!(
legacy_http_tool_text(&composed).as_deref(),
Some("compose:tool:alpha|resource:deterministic"),
"as_proxy WebSocket must retain the nested tool text and resource: {composed:?}"
);
let missing_tool = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy compose missing nested tool",
client.call_tool(
&cx,
&prefixed,
json!({"value": "alpha", "tool": "public-http-e2e-missing"}),
),
)
.await;
let missing_tool = match missing_tool {
Ok(result) => {
assert!(
result.is_error,
"changing only the nested tool name must stay a handler-visible refusal: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("compose-nested-tool"),
"as_proxy WebSocket compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
compose_upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_composes_nested_prompt() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy compose-prompt runtime installs an ambient context",
);
let compose_upstream = spawn_legacy_compose_prompt_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(compose_upstream.address(), "/sse")),
Some(public_http_target(compose_upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-compose-prompt-gateway".to_owned(),
"e2e-legacy-ws-proxy-compose-prompt-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket compose-prompt gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-compose-prompt-upstream",
"native-h1:e2e-legacy-ws-compose-prompt-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-compose-prompt".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP compose-prompt proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP compose-prompt proxy catalog is typed");
let server = legacy_2024::ServerBuilder::new(
"e2e-legacy-ws-compose-prompt-gateway",
"1.0.0",
)
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed compose-prompt install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy compose-prompt must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy compose-prompt publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy compose-prompt serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy compose-prompt handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket compose-prompt must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy compose-prompt initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-compose-prompt", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy compose-prompt over bind_websocket",
);
let prefixed = format!("child/{PUBLIC_HTTP_PROMPT_COMPOSE_TOOL_NAME}");
let missing = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy compose-prompt unprefixed",
client.call_tool(
&cx,
PUBLIC_HTTP_PROMPT_COMPOSE_TOOL_NAME,
json!({"subject": "alpha"}),
),
)
.await
.expect_err(
"changing only the tool name must not reach the unprefixed compose_prompt handler",
);
let _ = missing;
let composed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy compose-prompt tools/call",
client.call_tool(&cx, &prefixed, json!({"subject": "alpha"})),
)
.await
.expect("as_proxy WebSocket must forward prefixed compose_prompt onto the live exact-2024 HTTP upstream");
assert_eq!(
legacy_http_tool_text(&composed).as_deref(),
Some("compose-prompt:prompt:alpha"),
"as_proxy WebSocket must retain the nested prompt text: {composed:?}"
);
let missing_prompt = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy compose-prompt missing nested prompt",
client.call_tool(
&cx,
&prefixed,
json!({
"subject": "alpha",
"prompt": "public-http-e2e-missing",
}),
),
)
.await;
let missing_prompt = match missing_prompt {
Ok(result) => {
assert!(
result.is_error,
"changing only the nested prompt name must stay a handler-visible refusal: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("public-http-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy WebSocket compose_prompt must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
compose_upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_resource_composes_nested_tool_and_resource() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy resource-compose runtime installs an ambient context",
);
let compose_upstream = spawn_legacy_compose_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(compose_upstream.address(), "/sse")),
Some(public_http_target(compose_upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-resource-compose-gateway".to_owned(),
"e2e-legacy-ws-proxy-resource-compose-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket resource-compose gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-resource-compose-upstream",
"native-h1:e2e-legacy-ws-resource-compose-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-resource-compose".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP compose proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP compose proxy catalog is typed");
let server = legacy_2024::ServerBuilder::new(
"e2e-legacy-ws-resource-compose-gateway",
"1.0.0",
)
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed resource-compose install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy resource-compose must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy resource-compose publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy resource-compose serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy resource-compose handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket resource-compose must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy resource-compose initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-resource-compose", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy resource-compose over bind_websocket",
);
let prefixed = format!("child/{PUBLIC_HTTP_COMPOSE_RESOURCE_URI}");
let missing = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy resource-compose unprefixed",
client.read_resource(&cx, PUBLIC_HTTP_COMPOSE_RESOURCE_URI),
)
.await
.expect_err(
"changing only the missing prefix must not reach the unprefixed compose resource",
);
let _ = missing;
let composed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy resource-compose resources/read",
client.read_resource(&cx, &prefixed),
)
.await
.expect("as_proxy WebSocket must forward prefixed compose resources/read onto the live exact-2024 HTTP upstream");
let composed = serde_json::to_value(composed)
.expect("the exact-2024 WebSocket resource result serializes");
assert_eq!(
composed["contents"][0]["text"],
json!("compose:tool:alpha|resource:deterministic"),
"as_proxy WebSocket resources/read must retain the nested tool text and resource: {composed}"
);
let missing_tool = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy resource-compose missing nested tool",
client.read_resource(
&cx,
&format!("child/{PUBLIC_HTTP_COMPOSE_MISSING_TOOL_RESOURCE_URI}"),
),
)
.await;
let missing_tool = match missing_tool {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("compose-nested-tool"),
"as_proxy WebSocket resource compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
compose_upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_prompt_composes_nested_tool_and_resource() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy prompt-compose runtime installs an ambient context",
);
let compose_upstream = spawn_legacy_compose_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(compose_upstream.address(), "/sse")),
Some(public_http_target(compose_upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-prompt-compose-gateway".to_owned(),
"e2e-legacy-ws-proxy-prompt-compose-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket prompt-compose gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-prompt-compose-upstream",
"native-h1:e2e-legacy-ws-prompt-compose-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-prompt-compose".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP compose proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP compose proxy catalog is typed");
let server = legacy_2024::ServerBuilder::new(
"e2e-legacy-ws-prompt-compose-gateway",
"1.0.0",
)
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed prompt-compose install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy prompt-compose must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy prompt-compose publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy prompt-compose serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy prompt-compose handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket prompt-compose must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy prompt-compose initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-prompt-compose-handler", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy prompt-compose over bind_websocket",
);
let prefixed = format!("child/{PUBLIC_HTTP_COMPOSE_PROMPT_NAME}");
let missing = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy prompt-compose unprefixed",
client.get_prompt(
&cx,
PUBLIC_HTTP_COMPOSE_PROMPT_NAME,
HashMap::from([("value".to_owned(), "alpha".to_owned())]),
),
)
.await
.expect_err(
"changing only the missing prefix must not reach the unprefixed compose prompt",
);
let _ = missing;
let composed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy prompt-compose prompts/get",
client.get_prompt(
&cx,
&prefixed,
HashMap::from([("value".to_owned(), "alpha".to_owned())]),
),
)
.await
.expect("as_proxy WebSocket must forward prefixed compose prompts/get onto the live exact-2024 HTTP upstream");
let composed = serde_json::to_value(composed)
.expect("the exact-2024 WebSocket prompt result serializes");
assert_eq!(
composed["messages"][0]["content"]["text"],
json!("compose:tool:alpha|resource:deterministic"),
"as_proxy WebSocket prompts/get must retain the nested tool text and resource: {composed}"
);
let missing_tool = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy prompt-compose missing nested tool",
client.get_prompt(
&cx,
&prefixed,
HashMap::from([
("value".to_owned(), "alpha".to_owned()),
("tool".to_owned(), "public-http-e2e-missing".to_owned()),
]),
),
)
.await;
let missing_tool = match missing_tool {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing_tool.contains("public-http-e2e-missing")
|| missing_tool.contains("compose-nested-tool"),
"as_proxy WebSocket prompt compose must keep the nested missing-tool refusal: {missing_tool}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
compose_upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_resource_composes_nested_prompt() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy resource-compose-prompt runtime installs an ambient context",
);
let compose_upstream = spawn_legacy_compose_prompt_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(compose_upstream.address(), "/sse")),
Some(public_http_target(compose_upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-resource-compose-prompt-gateway".to_owned(),
"e2e-legacy-ws-proxy-resource-compose-prompt-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket resource-compose-prompt gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-resource-compose-prompt-upstream",
"native-h1:e2e-legacy-ws-resource-compose-prompt-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-resource-compose-prompt".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP compose-prompt proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP compose-prompt proxy catalog is typed");
let server = legacy_2024::ServerBuilder::new(
"e2e-legacy-ws-resource-compose-prompt-gateway",
"1.0.0",
)
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed resource-compose-prompt install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy resource-compose-prompt must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy resource-compose-prompt publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy resource-compose-prompt serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy resource-compose-prompt handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect(
"exact-2024 as_proxy WebSocket resource-compose-prompt must complete RFC 6455 upgrade",
);
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy resource-compose-prompt initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-resource-compose-prompt", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy resource-compose-prompt over bind_websocket",
);
let prefixed = format!("child/{PUBLIC_HTTP_PROMPT_COMPOSE_RESOURCE_URI}");
let missing = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy resource-compose-prompt unprefixed",
client.read_resource(&cx, PUBLIC_HTTP_PROMPT_COMPOSE_RESOURCE_URI),
)
.await
.expect_err(
"changing only the missing prefix must not reach the unprefixed compose-prompt resource",
);
let _ = missing;
let composed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy resource-compose-prompt resources/read",
client.read_resource(&cx, &prefixed),
)
.await
.expect("as_proxy WebSocket must forward prefixed compose-prompt resources/read onto the live exact-2024 HTTP upstream");
let composed = serde_json::to_value(composed)
.expect("the exact-2024 WebSocket resource result serializes");
assert_eq!(
composed["contents"][0]["text"],
json!("compose-prompt:prompt:alpha"),
"as_proxy WebSocket resources/read must retain the nested prompt text: {composed}"
);
let missing_prompt = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy resource-compose-prompt missing nested prompt",
client.read_resource(
&cx,
&format!("child/{PUBLIC_HTTP_PROMPT_COMPOSE_MISSING_RESOURCE_URI}"),
),
)
.await;
let missing_prompt = match missing_prompt {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("public-http-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy WebSocket resource compose-prompt must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
compose_upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_from_prompt_composes_nested_prompt() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy from-prompt runtime installs an ambient context",
);
let compose_upstream = spawn_legacy_compose_prompt_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(compose_upstream.address(), "/sse")),
Some(public_http_target(compose_upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-from-prompt-gateway".to_owned(),
"e2e-legacy-ws-proxy-from-prompt-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket from-prompt gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-from-prompt-upstream",
"native-h1:e2e-legacy-ws-from-prompt-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-from-prompt".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP from-prompt proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP from-prompt proxy catalog is typed");
let server = legacy_2024::ServerBuilder::new(
"e2e-legacy-ws-from-prompt-gateway",
"1.0.0",
)
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed from-prompt install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy from-prompt must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy from-prompt publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy from-prompt serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy from-prompt handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket from-prompt must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy from-prompt initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-from-prompt", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy from-prompt over bind_websocket",
);
let prefixed = format!("child/{PUBLIC_HTTP_FROM_PROMPT_NAME}");
let missing = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy from-prompt unprefixed",
client.get_prompt(
&cx,
PUBLIC_HTTP_FROM_PROMPT_NAME,
HashMap::from([("subject".to_owned(), "alpha".to_owned())]),
),
)
.await
.expect_err(
"changing only the missing prefix must not reach the unprefixed from-prompt",
);
let _ = missing;
let composed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy from-prompt prompts/get",
client.get_prompt(
&cx,
&prefixed,
HashMap::from([("subject".to_owned(), "alpha".to_owned())]),
),
)
.await
.expect("as_proxy WebSocket must forward prefixed from-prompt onto the live exact-2024 HTTP upstream");
let composed = serde_json::to_value(composed)
.expect("the exact-2024 WebSocket prompt result serializes");
assert_eq!(
composed["messages"][0]["content"]["text"],
json!("compose-prompt:prompt:alpha"),
"as_proxy WebSocket prompts/get must retain the nested prompt text: {composed}"
);
let missing_prompt = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy from-prompt missing nested prompt",
client.get_prompt(
&cx,
&prefixed,
HashMap::from([
("subject".to_owned(), "alpha".to_owned()),
("prompt".to_owned(), "public-http-e2e-missing".to_owned()),
]),
),
)
.await;
let missing_prompt = match missing_prompt {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
missing_prompt.contains("public-http-e2e-missing")
|| missing_prompt.contains("compose-nested-prompt"),
"as_proxy WebSocket from-prompt compose must keep the nested missing-prompt refusal: {missing_prompt}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
compose_upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_forwards_inbound_completion_progress_marker() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy completion-progress runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_completion_progress_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-complete-progress-gateway".to_owned(),
"e2e-legacy-ws-proxy-complete-progress-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket completion-progress gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-complete-progress-upstream",
"native-h1:e2e-legacy-ws-complete-progress-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-complete-progress".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP completion-progress proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP completion-progress proxy catalog is typed");
let server = legacy_2024::ServerBuilder::new(
"e2e-legacy-ws-complete-progress-gateway",
"1.0.0",
)
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed completion-progress install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy completion-progress must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy completion-progress publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy completion-progress serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy completion-progress handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect(
"exact-2024 as_proxy WebSocket completion-progress must complete RFC 6455 upgrade",
);
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy completion-progress initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-complete-progress", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy completion-progress over bind_websocket",
);
let prefixed = format!("child/{PUBLIC_HTTP_PROMPT_NAME}");
let params = legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt { name: prefixed },
argument: legacy_2024::LegacyCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
meta: None,
};
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy completion/complete before token",
client.complete(&cx, params.clone()),
)
.await
.expect("prefixed prompt complete must complete without a progressToken");
let silent = client
.take_server_notifications()
.expect("exact-2024 as_proxy WebSocket notifications before token must decode");
assert!(
!silent.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(_)
)),
"omitting only the progressToken must keep as_proxy WebSocket completion/complete silent: {silent:?}"
);
let mut unprefixed = params.clone();
unprefixed.reference = legacy_2024::LegacyCompletionReference::Prompt {
name: PUBLIC_HTTP_PROMPT_NAME.to_owned(),
};
websocket_client_bounded(
&cx,
"live exact-2024 as_proxy completion unprefixed",
client.complete_with_progress_marker(
&cx,
unprefixed,
legacy_2024::ProgressMarker::from("ws-legacy-as-proxy-complete-unprefixed"),
),
)
.await
.expect_err(
"changing only the prompt name must refuse before the unprefixed completion provider",
);
let marker = legacy_2024::ProgressMarker::from("ws-legacy-as-proxy-complete-progress");
let progressed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy completion/complete with token",
client.complete_with_progress_marker(&cx, params, marker.clone()),
)
.await
.expect("as_proxy WebSocket must forward an inbound progressToken onto prefixed completion/complete");
assert_eq!(
progressed.completion.values,
vec![PUBLIC_HTTP_COMPLETION_VALUE.to_owned()],
"as_proxy WebSocket must still retain the upstream completion values: {progressed:?}"
);
let progress = client
.take_server_notifications()
.expect("exact-2024 as_proxy WebSocket completion progress notifications must decode");
assert!(
progress.iter().any(|notification| matches!(
notification,
legacy_2024::ServerNotification::Progress(params)
if params.progress_marker == marker
&& params.message.as_deref() == Some("completion-legacy-halfway")
)),
"exact-2024 as_proxy WebSocket must retain upstream completion notifications/progress: {progress:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_request_timeout_of_hold_tool_without_handler_timeout() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy request_timeout runtime installs an ambient context",
);
let hold_upstream = spawn_legacy_as_proxy_hold_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(hold_upstream.address(), "/sse")),
Some(public_http_target(hold_upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-request-timeout-gateway".to_owned(),
"e2e-legacy-ws-proxy-request-timeout-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket request_timeout gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-request-timeout-upstream",
"native-h1:e2e-legacy-ws-request-timeout-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-request-timeout".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP hold proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP hold proxy catalog is typed");
let server = legacy_2024::ServerBuilder::new(
"e2e-legacy-ws-request-timeout-gateway",
"1.0.0",
)
.request_timeout(1)
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed request_timeout install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy request_timeout must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy request_timeout publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy request_timeout serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy request_timeout handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket request_timeout must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy request_timeout initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-request-timeout", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy request_timeout over bind_websocket",
);
let prefixed_hold = format!("child/{PUBLIC_HTTP_HOLD_TOOL_NAME}");
let prefixed_fast = format!("child/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let missing = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy request_timeout unprefixed",
client.call_tool(&cx, PUBLIC_HTTP_HOLD_TOOL_NAME, json!({})),
)
.await
.expect_err("changing only the tool name must not reach the unprefixed hold handler");
let _ = missing;
let timed_out = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy request_timeout hold",
client.call_tool(&cx, &prefixed_hold, json!({})),
)
.await;
let timed_out = match timed_out {
Ok(result) => {
assert!(
result.is_error,
"a hold tool without timeout() must stay an error under gateway request_timeout: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
timed_out.contains("Request timeout exceeded")
|| timed_out.contains("RequestCancelled")
|| timed_out.contains("Request cancelled"),
"gateway request_timeout(1) must refuse prefixed hold before it returns held: {timed_out}"
);
assert!(
!timed_out.contains("held"),
"changing only the missing handler timeout must not let hold complete: {timed_out}"
);
let peer = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy request_timeout peer",
client.call_tool(&cx, &prefixed_fast, json!({})),
)
.await
.expect("changing only the tool name must still reach the undisabled fast handler");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("fast"),
"as_proxy WebSocket must keep the prefixed fast peer callable after a request_timeout: {peer:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
hold_upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_forwards_hide_catalog_of_prefixed_resource_and_prompt()
{
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy hide-catalog runtime installs an ambient context",
);
let catalog_upstream = spawn_legacy_hide_catalog_http_server();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(catalog_upstream.address(), "/sse")),
Some(public_http_target(catalog_upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-hide-catalog-gateway".to_owned(),
"e2e-legacy-ws-proxy-hide-catalog-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket hide-catalog gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-hide-catalog-upstream",
"native-h1:e2e-legacy-ws-hide-catalog-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-hide-catalog".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP hide-catalog proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP hide-catalog proxy catalog is typed");
let server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-hide-catalog-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed hide-catalog install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy hide-catalog must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy hide-catalog publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy hide-catalog serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide-catalog handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket hide-catalog must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide-catalog initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-hide-catalog", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy hide-catalog over bind_websocket",
);
let prefixed_hide = format!("child/{PUBLIC_HTTP_HIDE_CATALOG_TOOL_NAME}");
let prefixed_show = format!("child/{PUBLIC_HTTP_SHOW_CATALOG_TOOL_NAME}");
let prefixed_fast = format!("child/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let prefixed_resource = format!("child/{PUBLIC_HTTP_WATCH_RESOURCE_URI}");
let prefixed_prompt = format!("child/{PUBLIC_HTTP_CATALOG_PROMPT_NAME}");
let before_resource = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide-catalog read before",
client.read_resource(&cx, &prefixed_resource),
)
.await
.expect("as_proxy WebSocket must read the prefixed watch resource before hide_catalog");
let before_resource_text = serde_json::to_value(&before_resource)
.ok()
.and_then(|value| value["contents"][0]["text"].as_str().map(str::to_owned));
assert_eq!(
before_resource_text.as_deref(),
Some("watched"),
"changing only the missing hide_catalog must keep the prefixed resource readable: {before_resource:?}"
);
let before_prompt = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide-catalog prompt before",
client.get_prompt(&cx, &prefixed_prompt, HashMap::new()),
)
.await
.expect("as_proxy WebSocket must get the prefixed catalog prompt before hide_catalog");
let before_prompt = serde_json::to_value(before_prompt)
.expect("the prefixed catalog prompt serializes");
assert_eq!(
before_prompt["messages"][0]["content"]["text"],
json!("catalog"),
"changing only the missing hide_catalog must keep the prefixed prompt admitted: {before_prompt:?}"
);
let hidden = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide_catalog tools/call",
client.call_tool(&cx, &prefixed_hide, json!({})),
)
.await
.expect("as_proxy WebSocket must forward hide_catalog");
assert_eq!(
legacy_http_tool_text(&hidden).as_deref(),
Some("hidden"),
"as_proxy WebSocket must retain the upstream disable_resource and disable_prompt mutations: {hidden:?}"
);
let still_resources = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide-catalog resources/list after",
client.list_resources(&cx),
)
.await
.expect("the gateway snapshot must keep the prefixed resource after hide_catalog");
assert!(
still_resources
.iter()
.any(|resource| resource.uri == prefixed_resource),
"as_proxy WebSocket must not drop the prefixed resource from the gateway snapshot: {still_resources:?}"
);
let disabled_resource = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide-catalog read after",
client.read_resource(&cx, &prefixed_resource),
)
.await;
let disabled_resource = match disabled_resource {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
disabled_resource.contains("disabled")
|| disabled_resource.contains(PUBLIC_HTTP_WATCH_RESOURCE_URI)
|| disabled_resource.contains("Method not found")
|| disabled_resource.contains("MethodNotFound"),
"hide_catalog on the live exact-2024 WebSocket session must refuse a later prefixed resource read: {disabled_resource}"
);
let disabled_prompt = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide-catalog prompt after",
client.get_prompt(&cx, &prefixed_prompt, HashMap::new()),
)
.await;
let disabled_prompt = match disabled_prompt {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
disabled_prompt.contains("disabled")
|| disabled_prompt.contains(PUBLIC_HTTP_CATALOG_PROMPT_NAME)
|| disabled_prompt.contains("Method not found")
|| disabled_prompt.contains("MethodNotFound"),
"hide_catalog on the live exact-2024 WebSocket session must refuse a later prefixed prompt get: {disabled_prompt}"
);
let peer = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide-catalog peer",
client.call_tool(&cx, &prefixed_fast, json!({})),
)
.await
.expect("changing only the missing hide_catalog must keep an undisabled HTTP tool callable");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("fast"),
"as_proxy WebSocket must keep undisabled HTTP tools callable after hide_catalog: {peer:?}"
);
let shown = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy show_catalog tools/call",
client.call_tool(&cx, &prefixed_show, json!({})),
)
.await
.expect("as_proxy WebSocket must forward show_catalog");
assert_eq!(
legacy_http_tool_text(&shown).as_deref(),
Some("shown"),
"as_proxy WebSocket must retain the upstream enable_resource and enable_prompt mutations: {shown:?}"
);
let restored_resource = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy hide-catalog read restored",
client.read_resource(&cx, &prefixed_resource),
)
.await
.expect("show_catalog must restore the prefixed watch resource");
let restored_resource_text = serde_json::to_value(&restored_resource)
.ok()
.and_then(|value| value["contents"][0]["text"].as_str().map(str::to_owned));
assert_eq!(
restored_resource_text.as_deref(),
Some("watched"),
"as_proxy WebSocket must keep the restored HTTP resource readable: {restored_resource:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
catalog_upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_mask_error_details_hides_upstream_resource_secret() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy mask runtime installs an ambient context",
);
let leak_upstream = spawn_legacy_as_proxy_leak_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(leak_upstream.address(), "/sse")),
Some(public_http_target(leak_upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-mask-gateway".to_owned(),
"e2e-legacy-ws-proxy-mask-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket mask gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-mask-upstream",
"native-h1:e2e-legacy-ws-mask-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-mask".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP leak proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP leak proxy catalog is typed");
let server = legacy_2024::ServerBuilder::new("e2e-legacy-ws-mask-gateway", "1.0.0")
.mask_error_details(true)
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed mask install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy mask must bind");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket as_proxy mask publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket as_proxy mask serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy mask handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket mask must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy mask initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-mask", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates as_proxy mask over bind_websocket");
let prefixed = format!("child/{PUBLIC_HTTP_LEAK_RESOURCE_URI}");
let missing = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy mask unprefixed leak",
client.read_resource(&cx, PUBLIC_HTTP_LEAK_RESOURCE_URI),
)
.await
.expect_err("changing only the missing prefix must not reach the unprefixed leak resource");
let _ = missing;
let masked = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy mask leak read",
client.read_resource(&cx, &prefixed),
)
.await;
let masked = match masked {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
masked.contains("Internal server error"),
"gateway mask_error_details must replace the upstream execution secret over WebSocket: {masked}"
);
assert!(
!masked.contains(PUBLIC_HTTP_LEAK_SECRET),
"gateway mask_error_details must not leak the upstream execution secret over WebSocket: {masked}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
leak_upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_forwards_handler_timeout_of_prefixed_slow_tool() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy timeout runtime installs an ambient context",
);
let timeout_upstream = spawn_legacy_as_proxy_timeout_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(timeout_upstream.address(), "/sse")),
Some(public_http_target(timeout_upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-timeout-gateway".to_owned(),
"e2e-legacy-ws-proxy-timeout-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket timeout gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-timeout-upstream",
"native-h1:e2e-legacy-ws-timeout-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-timeout".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP timeout proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP timeout proxy catalog is typed");
let server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-timeout-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed timeout install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy timeout must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy timeout publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket as_proxy timeout serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy timeout handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket timeout must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy timeout initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-timeout", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy timeout over bind_websocket",
);
let prefixed_slow = format!("child/{PUBLIC_HTTP_SLOW_TOOL_NAME}");
let prefixed_fast = format!("child/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let missing = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy timeout unprefixed",
client.call_tool(&cx, PUBLIC_HTTP_SLOW_TOOL_NAME, json!({})),
)
.await
.expect_err("changing only the tool name must not reach the unprefixed slow handler");
let _ = missing;
let timed_out = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy timeout slow",
client.call_tool(&cx, &prefixed_slow, json!({})),
)
.await;
let timed_out = match timed_out {
Ok(result) => {
assert!(
result.is_error,
"a handler that outlives its timeout must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
timed_out.contains("Request timeout exceeded")
|| timed_out.contains("RequestCancelled"),
"the refused prefixed slow tools/call must keep the handler-timeout error over WebSocket: {timed_out}"
);
let peer = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy timeout peer",
client.call_tool(&cx, &prefixed_fast, json!({})),
)
.await
.expect("changing only the tool name must still reach the undisabled fast handler");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("fast"),
"as_proxy WebSocket must keep the prefixed fast peer callable after a handler timeout: {peer:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
timeout_upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_sanitizes_prefixed_handler_panic() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy panic runtime installs an ambient context",
);
let panic_upstream = spawn_legacy_as_proxy_panic_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(panic_upstream.address(), "/sse")),
Some(public_http_target(panic_upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-panic-gateway".to_owned(),
"e2e-legacy-ws-proxy-panic-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket panic gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-panic-upstream",
"native-h1:e2e-legacy-ws-panic-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-panic".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP panic proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP panic proxy catalog is typed");
let server = legacy_2024::ServerBuilder::new("e2e-legacy-ws-panic-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed panic install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy panic must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy panic publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket as_proxy panic serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy panic handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket panic must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy panic initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-panic", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy panic over bind_websocket",
);
let prefixed = format!("child/{PUBLIC_HTTP_PANIC_TOOL_NAME}");
let prefixed_fast = format!("child/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let missing = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy panic unprefixed",
client.call_tool(&cx, PUBLIC_HTTP_PANIC_TOOL_NAME, json!({})),
)
.await
.expect_err("changing only the tool name must not reach the unprefixed panic tool");
let _ = missing;
let panicked = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy panic tools/call",
client.call_tool(&cx, &prefixed, json!({})),
)
.await;
let panicked = match panicked {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
panicked.contains("Internal server error") || panicked.contains("InternalError"),
"exact-2024 as_proxy WebSocket must sanitize an upstream handler panic: {panicked}"
);
assert!(
!panicked.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"exact-2024 as_proxy WebSocket must not leak the upstream unwind payload: {panicked}"
);
let peer = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy panic fast peer",
client.call_tool(&cx, &prefixed_fast, json!({})),
)
.await
.expect("changing only the tool must still be admitted after a sanitized exact-2024 as_proxy panic");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("fast"),
"the prefixed fast peer tool must still complete after a sanitized as_proxy panic: {peer:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
panic_upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_sanitizes_prefixed_catalog_and_completion_panic() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy catalog-panic runtime installs an ambient context",
);
let panic_upstream = spawn_legacy_as_proxy_panic_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(panic_upstream.address(), "/sse")),
Some(public_http_target(panic_upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-catalog-panic-gateway".to_owned(),
"e2e-legacy-ws-proxy-catalog-panic-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket catalog-panic gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-catalog-panic-upstream",
"native-h1:e2e-legacy-ws-catalog-panic-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-catalog-panic".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP catalog-panic proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP catalog-panic proxy catalog is typed");
let server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-catalog-panic-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed catalog-panic install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy catalog-panic must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy catalog-panic publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy catalog-panic serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy catalog-panic handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket catalog-panic must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy catalog-panic initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-catalog-panic", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy catalog-panic over bind_websocket",
);
let prefixed_resource = format!("child/{PUBLIC_HTTP_PANIC_RESOURCE_URI}");
let prefixed_peer_resource = format!("child/{PUBLIC_HTTP_RESOURCE_URI}");
let prefixed_prompt = format!("child/{PUBLIC_HTTP_PANIC_PROMPT_NAME}");
let prefixed_peer_prompt = format!("child/{PUBLIC_HTTP_PROMPT_NAME}");
let prefixed_fast = format!("child/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let missing_resource = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy catalog-panic unprefixed resource",
client.read_resource(&cx, PUBLIC_HTTP_PANIC_RESOURCE_URI),
)
.await
.expect_err("changing only the missing prefix must not reach the unprefixed panic resource");
let _ = missing_resource;
let panicked_resource = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy catalog-panic resources/read",
client.read_resource(&cx, &prefixed_resource),
)
.await;
let panicked_resource = match panicked_resource {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
panicked_resource.contains("Internal server error")
|| panicked_resource.contains("InternalError"),
"exact-2024 as_proxy WebSocket must sanitize an upstream resource panic: {panicked_resource}"
);
assert!(
!panicked_resource.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"exact-2024 as_proxy WebSocket must not leak the upstream resource unwind payload: {panicked_resource}"
);
let missing_prompt = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy catalog-panic unprefixed prompt",
client.get_prompt(&cx, PUBLIC_HTTP_PANIC_PROMPT_NAME, HashMap::new()),
)
.await
.expect_err("changing only the missing prefix must not reach the unprefixed panic prompt");
let _ = missing_prompt;
let panicked_prompt = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy catalog-panic prompts/get",
client.get_prompt(&cx, &prefixed_prompt, HashMap::new()),
)
.await;
let panicked_prompt = match panicked_prompt {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
panicked_prompt.contains("Internal server error")
|| panicked_prompt.contains("InternalError"),
"exact-2024 as_proxy WebSocket must sanitize an upstream prompt panic: {panicked_prompt}"
);
assert!(
!panicked_prompt.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"exact-2024 as_proxy WebSocket must not leak the upstream prompt unwind payload: {panicked_prompt}"
);
let missing_complete = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy completion-panic unprefixed",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt {
name: PUBLIC_HTTP_PROMPT_NAME.to_owned(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
meta: None,
},
),
)
.await;
assert!(
missing_complete.is_err(),
"changing only the completion prompt name must refuse before the unprefixed provider: {missing_complete:?}"
);
let panicked_complete = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy completion-panic complete",
client.complete(
&cx,
legacy_2024::LegacyCompletionParams {
reference: legacy_2024::LegacyCompletionReference::Prompt {
name: prefixed_peer_prompt.clone(),
},
argument: legacy_2024::LegacyCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
meta: None,
},
),
)
.await;
let panicked_complete = match panicked_complete {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
panicked_complete.contains("Internal server error")
|| panicked_complete.contains("InternalError"),
"exact-2024 as_proxy WebSocket must sanitize an upstream completion panic: {panicked_complete}"
);
assert!(
!panicked_complete.contains(PUBLIC_HTTP_PANIC_PAYLOAD),
"exact-2024 as_proxy WebSocket must not leak the upstream completion unwind payload: {panicked_complete}"
);
let peer_resource = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy catalog-panic peer resource",
client.read_resource(&cx, &prefixed_peer_resource),
)
.await
.expect("changing only the resource must still be admitted after a sanitized exact-2024 as_proxy panic");
let peer_resource = format!("{peer_resource:?}");
assert!(
peer_resource.contains(PUBLIC_HTTP_RESOURCE_TEXT),
"the prefixed peer resource must still complete after a sanitized as_proxy panic: {peer_resource}"
);
let peer_prompt = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy catalog-panic peer prompt",
client.get_prompt(
&cx,
&prefixed_peer_prompt,
HashMap::from([(
"subject".to_owned(),
PUBLIC_HTTP_TOOL_ARGUMENT.to_owned(),
)]),
),
)
.await
.expect("changing only the prompt must still be admitted after a sanitized exact-2024 as_proxy panic");
let peer_prompt = format!("{peer_prompt:?}");
assert!(
peer_prompt.contains(PUBLIC_HTTP_PROMPT_TEXT),
"the prefixed peer prompt must still complete after a sanitized as_proxy panic: {peer_prompt}"
);
let peer = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy catalog-panic fast peer",
client.call_tool(&cx, &prefixed_fast, json!({})),
)
.await
.expect("changing only the method must still admit tools/call after a sanitized exact-2024 as_proxy panic");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("fast"),
"the prefixed fast peer tool must still complete after a sanitized as_proxy catalog panic: {peer:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
panic_upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_gateway_cache_hits_prefixed_allowlisted_tool() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy cache runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_counting_upstream();
let prefixed = format!("child/{PUBLIC_HTTP_TOOL_NAME}");
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-cache-gateway".to_owned(),
"e2e-legacy-ws-proxy-cache-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket cache gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-cache-upstream",
"native-h1:e2e-legacy-ws-cache-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-cache".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP counting proxy catalog is typed");
let server = legacy_2024::ServerBuilder::new("e2e-legacy-ws-cache-gateway", "1.0.0")
.middleware(
caching::ResponseCachingMiddleware::new().include_tools(vec![prefixed.clone()]),
)
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed cache install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy cache must bind");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket as_proxy cache publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket as_proxy cache serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy cache handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket cache must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy cache initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-cache", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates as_proxy cache over bind_websocket");
let missing = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy cache unprefixed",
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await;
assert!(
missing.is_err(),
"changing only the missing prefix must not reach the unprefixed counting tool: {missing:?}"
);
let first = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy cache miss",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("the first allowlisted prefixed tools/call must miss and reach the upstream");
assert_eq!(
legacy_http_tool_text(&first).as_deref(),
Some("tool:alpha"),
"the first exact-2024 as_proxy WebSocket cache miss must return the upstream handler result: {first:?}"
);
assert_eq!(
upstream.handler_call_snapshot().tool,
1,
"the first allowlisted prefixed tools/call must invoke the upstream counting handler"
);
let cached = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy cache hit",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("the second identical prefixed tools/call must be a gateway cache hit");
assert_eq!(
legacy_http_tool_text(&cached).as_deref(),
Some("tool:alpha"),
"the exact-2024 as_proxy WebSocket cache hit must keep the first complete result: {cached:?}"
);
assert_eq!(
upstream.handler_call_snapshot().tool,
1,
"a gateway cache hit must not invoke the upstream counting handler again"
);
let missed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy cache arg miss",
client.call_tool(&cx, &prefixed, json!({"value": "beta"})),
)
.await
.expect("changing only the arguments must miss the gateway cache");
assert_eq!(
legacy_http_tool_text(&missed).as_deref(),
Some("tool:beta"),
"the exact-2024 as_proxy WebSocket cache miss must reach the upstream with the new arguments: {missed:?}"
);
assert_eq!(
upstream.handler_call_snapshot().tool,
2,
"a different-arguments prefixed tools/call must invoke the upstream counting handler"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_gateway_rate_limit_refuses_second_prefixed_call() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy rate-limit runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_counting_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-rate-limit-gateway".to_owned(),
"e2e-legacy-ws-proxy-rate-limit-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket rate-limit gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-rate-limit-upstream",
"native-h1:e2e-legacy-ws-rate-limit-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-rate-limit".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP counting proxy catalog is typed");
let server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-rate-limit-gateway", "1.0.0")
.middleware(
rate_limiting::RateLimitingMiddleware::new(1.0e-300).burst_capacity(1),
)
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed rate-limit install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy rate-limit must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy rate-limit publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy rate-limit serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy rate-limit handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket rate-limit must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy rate-limit initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-rate-limit", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy rate-limit over bind_websocket",
);
let prefixed = format!("child/{PUBLIC_HTTP_TOOL_NAME}");
let first = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy rate-limit first",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("the first prefixed tools/call must be admitted by the gateway rate limiter");
assert_eq!(
legacy_http_tool_text(&first).as_deref(),
Some("tool:alpha"),
"the first exact-2024 as_proxy WebSocket rate-limited tools/call must reach the upstream: {first:?}"
);
let limited = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy rate-limit second",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await;
let limited = match limited {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
limited.contains("Rate limit exceeded"),
"the refused second exact-2024 as_proxy WebSocket tools/call must keep the rate-limit error: {limited}"
);
let listed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy rate-limit tools/list",
client.list_tools(&cx),
)
.await
.expect("changing only the method must still be admitted by the gateway rate limiter");
assert!(
listed.iter().any(|tool| tool.name == prefixed),
"tools/list must stay callable after prefixed tools/call is rate-limited: {listed:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_gateway_sliding_window_refuses_second_prefixed_call() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy sliding-window runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_counting_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-sliding-gateway".to_owned(),
"e2e-legacy-ws-proxy-sliding-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket sliding-window gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-sliding-upstream",
"native-h1:e2e-legacy-ws-sliding-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-sliding".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP counting proxy catalog is typed");
let server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-sliding-gateway", "1.0.0")
.middleware(rate_limiting::SlidingWindowRateLimitingMiddleware::new(
1, 60,
))
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed sliding-window install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy sliding-window must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy sliding-window publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy sliding-window serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy sliding handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket sliding-window must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy sliding initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-sliding", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy sliding-window over bind_websocket",
);
let prefixed = format!("child/{PUBLIC_HTTP_TOOL_NAME}");
let first = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy sliding first",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("the first prefixed tools/call must be admitted by the gateway sliding window");
assert_eq!(
legacy_http_tool_text(&first).as_deref(),
Some("tool:alpha"),
"the first exact-2024 as_proxy WebSocket sliding-window tools/call must reach the upstream: {first:?}"
);
let limited = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy sliding second",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await;
let limited = match limited {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
limited.contains("Rate limit exceeded"),
"the refused second exact-2024 as_proxy WebSocket tools/call must keep the sliding-window error: {limited}"
);
let listed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy sliding tools/list",
client.list_tools(&cx),
)
.await
.expect("changing only the method must still be admitted by the gateway sliding window");
assert!(
listed.iter().any(|tool| tool.name == prefixed),
"tools/list must stay callable after prefixed tools/call is sliding-window limited: {listed:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_gateway_static_token_refuses_missing_and_wrong() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy auth runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_counting_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-auth-gateway".to_owned(),
"e2e-legacy-ws-proxy-auth-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket auth gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-auth-upstream",
"native-h1:e2e-legacy-ws-auth-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-auth".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP counting proxy catalog is typed");
let verifier = StaticTokenVerifier::new([(
"alpha",
AuthContext::with_subject(PUBLIC_HTTP_AUTH_SUBJECT),
)])
.expect("the deterministic exact-2024 as_proxy WebSocket bearer verifier is valid")
.with_allowed_schemes(["Bearer"])
.expect("the exact-2024 as_proxy WebSocket bearer scheme allowlist is valid");
let server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-auth-gateway", "1.0.0")
.auth_provider(TokenAuthProvider::new(verifier))
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed auth install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy auth must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy auth publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket as_proxy auth serve must be admitted");
let (missing, _missing_stream) = websocket_upgrade_status(&cx, address, None).await;
assert!(
missing.starts_with(b"HTTP/1.1 401"),
"omitting Authorization must refuse the exact-2024 as_proxy WebSocket upgrade: {}",
String::from_utf8_lossy(&missing)
);
assert!(
String::from_utf8_lossy(&missing)
.to_ascii_lowercase()
.contains("www-authenticate"),
"the missing-token exact-2024 as_proxy WebSocket upgrade must challenge Bearer: {}",
String::from_utf8_lossy(&missing)
);
assert_eq!(
upstream.handler_call_snapshot().tool,
0,
"missing Authorization must not reach the as_proxy upstream"
);
let (wrong, _wrong_stream) =
websocket_upgrade_status(&cx, address, Some("Bearer beta")).await;
assert!(
wrong.starts_with(b"HTTP/1.1 401"),
"a wrong bearer token must refuse the exact-2024 as_proxy WebSocket upgrade: {}",
String::from_utf8_lossy(&wrong)
);
assert_eq!(
upstream.handler_call_snapshot().tool,
0,
"a wrong bearer token must not reach the as_proxy upstream"
);
let (accepted, stream) =
websocket_upgrade_status(&cx, address, Some("Bearer alpha")).await;
assert!(
accepted.starts_with(b"HTTP/1.1 101"),
"the matching bearer token must complete RFC 6455 upgrade: {}",
String::from_utf8_lossy(&accepted)
);
let transport = AsyncWsClientTransport::from_upgraded(stream);
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy auth initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-auth", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("an admitted exact-2024 as_proxy WebSocket upgrade must negotiate LegacyOnly");
let prefixed = format!("child/{PUBLIC_HTTP_TOOL_NAME}");
let admitted = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy auth tools/call",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("the matching bearer token must reach the prefixed as_proxy upstream");
assert_eq!(
legacy_http_tool_text(&admitted).as_deref(),
Some("tool:alpha"),
"the matching exact-2024 as_proxy WebSocket bearer token must reach the upstream: {admitted:?}"
);
assert_eq!(
upstream.handler_call_snapshot().tool,
1,
"only the matching bearer token may invoke the as_proxy upstream"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_gateway_session_state_uses_shared_upstream_bag() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy session-state runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_state_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-state-gateway".to_owned(),
"e2e-legacy-ws-proxy-state-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket session-state gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-state-upstream",
"native-h1:e2e-legacy-ws-state-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-state".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP state proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP state proxy catalog is typed");
let server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-state-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed session-state install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy session-state must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy session-state publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy session-state serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy session-state handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket session-state must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy session-state initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-state", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy session-state over bind_websocket",
);
let peer_transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy session-state peer handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy peer WebSocket must complete RFC 6455 upgrade");
let mut peer = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy session-state peer initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-state-peer", "1.0.0")
.connect_websocket_with_cx(&cx, peer_transport),
)
.await
.expect("a peer inbound WebSocket must still negotiate LegacyOnly");
let prefixed = format!("child/{PUBLIC_HTTP_STATE_TOOL_NAME}");
let written = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy session-state write",
client.call_tool(
&cx,
&prefixed,
json!({"action": "write", "value": "alpha"}),
),
)
.await
.expect("exact-2024 as_proxy WebSocket must write session state on the upstream binding");
assert_eq!(
legacy_http_tool_text(&written).as_deref(),
Some("state:alpha"),
"set_state then get_state on the same as_proxy WebSocket write must retain: {written:?}"
);
let later_read = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy session-state later read",
client.call_tool(&cx, &prefixed, json!({"action": "read"})),
)
.await
.expect("a later exact-2024 as_proxy WebSocket tools/call must still be admitted");
assert_eq!(
legacy_http_tool_text(&later_read).as_deref(),
Some("state:alpha"),
"the as_proxy upstream binding must reuse the previous bag: {later_read:?}"
);
let peer_read = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy session-state peer read",
peer.call_tool(&cx, &prefixed, json!({"action": "read"})),
)
.await
.expect("a peer inbound WebSocket must still be admitted");
assert_eq!(
legacy_http_tool_text(&peer_read).as_deref(),
Some("state:alpha"),
"as_proxy must keep one upstream session bag across inbound clients: {peer_read:?}"
);
drop(client);
drop(peer);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_gateway_strict_input_refuses_unknown_property() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy strict runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_counting_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-strict-gateway".to_owned(),
"e2e-legacy-ws-proxy-strict-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket strict gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-strict-upstream",
"native-h1:e2e-legacy-ws-strict-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-strict".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP counting proxy catalog is typed");
let strict_server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-strict-gateway", "1.0.0")
.strict_input_validation(true)
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed strict install must succeed")
.build();
let mut registry = ProxyClient::upstream_binding_registry();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-lenient-gateway".to_owned(),
"e2e-legacy-ws-proxy-lenient-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket lenient gateway HTTP plan is valid");
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-lenient-upstream",
"native-h1:e2e-legacy-ws-lenient-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-lenient".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP counting proxy catalog is typed");
let lenient_server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-lenient-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed lenient install must succeed")
.build();
let strict_bound = strict_server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy strict must bind");
let strict_address = strict_bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy strict publishes its address",
);
let lenient_bound = lenient_server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy lenient must bind");
let lenient_address = lenient_bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy lenient publishes its address",
);
let scope = cx.scope();
let strict_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
strict_bound.serve(&serve_cx).await
})
.expect("public LegacyOnly bind_websocket as_proxy strict serve must be admitted");
let lenient_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
lenient_bound.serve(&serve_cx).await
})
.expect("public LegacyOnly bind_websocket as_proxy lenient serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy strict handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{strict_address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket strict must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy strict initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-strict", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy strict over bind_websocket",
);
let prefixed = format!("child/{PUBLIC_HTTP_TOOL_NAME}");
let admitted = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy strict declared",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("strict as_proxy must still admit declared arguments");
assert_eq!(
legacy_http_tool_text(&admitted).as_deref(),
Some("tool:alpha"),
"declared arguments must reach the prefixed upstream under gateway strict validation: {admitted:?}"
);
let refused = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy strict extra-property",
client.call_tool(&cx, &prefixed, json!({"value": "alpha", "extra": 1})),
)
.await;
let refused = match refused {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
refused.contains("do not match the declared input schema")
|| refused.contains("additional property")
|| refused.contains("unknown"),
"the exact-2024 as_proxy WebSocket strict refusal must name the input-schema mismatch: {refused}"
);
drop(client);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy lenient handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{lenient_address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket lenient must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy lenient initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-lenient", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy lenient over bind_websocket",
);
let extra = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy lenient extra-property",
client.call_tool(&cx, &prefixed, json!({"value": "alpha", "extra": 1})),
)
.await
.expect("lenient as_proxy must admit the same extra property");
assert_eq!(
legacy_http_tool_text(&extra).as_deref(),
Some("tool:alpha"),
"changing only the gateway strict flag must admit the extra property: {extra:?}"
);
drop(client);
cx.set_cancel_requested(true);
strict_listener.abort();
lenient_listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_gateway_list_page_size_pages_prefixed_tools() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy list_page_size runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_counting_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-page-gateway".to_owned(),
"e2e-legacy-ws-proxy-page-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket list_page_size gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-page-upstream",
"native-h1:e2e-legacy-ws-page-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-page".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP counting proxy catalog is typed");
let paged_server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-page-gateway", "1.0.0")
.list_page_size(1)
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed list_page_size install must succeed")
.build();
let mut registry = ProxyClient::upstream_binding_registry();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-unpaged-gateway".to_owned(),
"e2e-legacy-ws-proxy-unpaged-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket default-list gateway HTTP plan is valid");
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-unpaged-upstream",
"native-h1:e2e-legacy-ws-unpaged-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-unpaged".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP counting proxy catalog is typed");
let unpaged_server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-unpaged-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed default-list install must succeed")
.build();
let paged_bound = paged_server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy list_page_size must bind");
let paged_address = paged_bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy list_page_size publishes its address",
);
let unpaged_bound = unpaged_server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy default list must bind");
let unpaged_address = unpaged_bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy default list publishes its address",
);
let scope = cx.scope();
let paged_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
paged_bound.serve(&serve_cx).await
})
.expect(
"public LegacyOnly bind_websocket as_proxy list_page_size serve must be admitted",
);
let unpaged_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
unpaged_bound.serve(&serve_cx).await
})
.expect(
"public LegacyOnly bind_websocket as_proxy default list serve must be admitted",
);
let prefixed = format!("child/{PUBLIC_HTTP_TOOL_NAME}");
let prefixed_fast = format!("child/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy list_page_size handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{paged_address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket list_page_size must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy list_page_size initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-page", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy list_page_size over bind_websocket",
);
let first = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy list_page_size first",
client.list_tools_page(&cx, legacy_2024::ListToolsParams::default()),
)
.await
.expect("gateway list_page_size(1) must page prefixed tools");
assert_eq!(
first.tools.len(),
1,
"as_proxy gateway list_page_size(1) must create a real continuation: {first:?}"
);
let first_name = first.tools[0].name.clone();
assert!(
first_name == prefixed || first_name == prefixed_fast,
"the first as_proxy page must retain a prefixed catalog tool: {first:?}"
);
let cursor = first
.next_cursor
.clone()
.expect("the first as_proxy page must carry an opaque cursor");
let second = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy list_page_size continuation",
client.list_tools_page(
&cx,
legacy_2024::ListToolsParams {
cursor: Some(cursor),
include_tags: None,
exclude_tags: None,
},
),
)
.await
.expect("the as_proxy cursor continuation must reach the second page");
assert_eq!(
second.tools.len(),
1,
"the as_proxy continuation must retain one remaining prefixed tool: {second:?}"
);
assert_ne!(
second.tools[0].name, first_name,
"the continuation must retain the other prefixed tool: {second:?}"
);
drop(client);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy default-list handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{unpaged_address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket default list must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy default-list initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-unpaged", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy default list over bind_websocket",
);
let listed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy default list",
client.list_tools_page(&cx, legacy_2024::ListToolsParams::default()),
)
.await
.expect("the default as_proxy catalog must still list prefixed tools");
assert!(
listed.tools.len() >= 2,
"changing only the missing list_page_size must not invent a 1-item page: {listed:?}"
);
drop(client);
cx.set_cancel_requested(true);
paged_listener.abort();
unpaged_listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_gateway_on_duplicate_error_keeps_and_replace_installs()
{
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy on_duplicate runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_counting_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-as-proxy-duplicate-error-gateway".to_owned(),
"e2e-legacy-ws-as-proxy-duplicate-error-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket on_duplicate Error gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-as-proxy-duplicate-error-upstream",
"native-h1:e2e-legacy-ws-as-proxy-duplicate-error-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-as-proxy-duplicate-error".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities {
sampling: Some(Default::default()),
elicitation: None,
roots: Some(legacy_2024::RootsCapability {
list_changed: false,
}),
},
cx.clone(),
)
.expect(
"live exact-2024 HTTP counting proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP counting proxy catalog is typed");
let error_server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-as-proxy-duplicate-error", "1.0.0")
.on_duplicate(DuplicateBehavior::Error)
.tool(LocalPrefixedPublicHttpValue {
name: format!("child/{PUBLIC_HTTP_TOOL_NAME}"),
})
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed on_duplicate Error install must succeed")
.build();
let bound = error_server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy on_duplicate Error must bind");
let error_address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy on_duplicate Error publishes its address",
);
let scope = cx.scope();
let error_listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy on_duplicate Error serve must be admitted",
);
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-as-proxy-duplicate-replace-gateway".to_owned(),
"e2e-legacy-ws-as-proxy-duplicate-replace-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket on_duplicate Replace gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-as-proxy-duplicate-replace-upstream",
"native-h1:e2e-legacy-ws-as-proxy-duplicate-replace-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-as-proxy-duplicate-replace".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities {
sampling: Some(Default::default()),
elicitation: None,
roots: Some(legacy_2024::RootsCapability {
list_changed: false,
}),
},
cx.clone(),
)
.expect(
"live exact-2024 HTTP counting proxy upstream must connect for the Replace gateway",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP counting proxy catalog is typed for Replace");
let replace_server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-as-proxy-duplicate-replace", "1.0.0")
.on_duplicate(DuplicateBehavior::Replace)
.tool(LocalPrefixedPublicHttpValue {
name: format!("child/{PUBLIC_HTTP_TOOL_NAME}"),
})
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed on_duplicate Replace install must succeed")
.build();
let bound = replace_server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy on_duplicate Replace must bind");
let replace_address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy on_duplicate Replace publishes its address",
);
let replace_listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy on_duplicate Replace serve must be admitted",
);
let prefixed = format!("child/{PUBLIC_HTTP_TOOL_NAME}");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy on_duplicate Error handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{error_address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket on_duplicate Error must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy on_duplicate Error initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-duplicate-error", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy on_duplicate Error over bind_websocket",
);
let kept = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy on_duplicate Error call",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("Error must keep the local prefixed tool callable");
assert_eq!(
legacy_http_tool_text(&kept).as_deref(),
Some("local:alpha"),
"as_proxy on_duplicate Error must keep the local prefixed handler: {kept:?}"
);
assert_eq!(
upstream.handler_calls.tool.load(Ordering::SeqCst),
0,
"Error must not forward the colliding prefixed name to the upstream"
);
drop(client);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy on_duplicate Replace handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{replace_address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket on_duplicate Replace must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy on_duplicate Replace initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-duplicate-replace", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy on_duplicate Replace over bind_websocket",
);
let replaced = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy on_duplicate Replace call",
client.call_tool(&cx, &prefixed, json!({"value": "alpha"})),
)
.await
.expect("Replace must install the proxied catalog entry");
assert_eq!(
legacy_http_tool_text(&replaced).as_deref(),
Some("tool:alpha"),
"changing only on_duplicate to Replace must reach the prefixed upstream: {replaced:?}"
);
assert_eq!(
upstream.handler_calls.tool.load(Ordering::SeqCst),
1,
"Replace must forward the colliding prefixed name to the upstream"
);
drop(client);
cx.set_cancel_requested(true);
error_listener.abort();
replace_listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_forwards_transformed_prefixed_tool() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy transform runtime installs an ambient context",
);
let upstream = spawn_legacy_as_proxy_transform_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(upstream.address(), "/sse")),
Some(public_http_target(upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-transform-gateway".to_owned(),
"e2e-legacy-ws-proxy-transform-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket transform gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-transform-upstream",
"native-h1:e2e-legacy-ws-transform-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-transform".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP transform proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP transform proxy catalog is typed");
let server =
legacy_2024::ServerBuilder::new("e2e-legacy-ws-transform-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed transform install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy transform must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket as_proxy transform publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect(
"public LegacyOnly bind_websocket as_proxy transform serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy transform handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket transform must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy transform initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-transform", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates as_proxy transform over bind_websocket",
);
let prefixed = format!("child/{PUBLIC_HTTP_TRANSFORMED_TOOL_NAME}");
let listed = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy transform tools/list",
client.list_tools(&cx),
)
.await
.expect("exact-2024 as_proxy WebSocket must list the prefixed transformed tool");
let transformed = listed
.iter()
.find(|tool| tool.name == prefixed)
.unwrap_or_else(|| {
panic!(
"the exact-2024 as_proxy WebSocket catalog must advertise the prefixed transformed name: {listed:?}"
)
});
assert!(
listed.iter().all(|tool| tool.name != PUBLIC_HTTP_TRANSFORMED_TOOL_NAME
&& tool.name != PUBLIC_HTTP_TOOL_NAME),
"exact-2024 as_proxy WebSocket must not keep the unprefixed transformed name or the parent tool: {listed:?}"
);
let schema = serde_json::to_string(&transformed.input_schema)
.expect("the prefixed transformed input schema serializes");
assert!(
schema.contains("query"),
"exact-2024 as_proxy WebSocket must retain the renamed argument on the prefixed catalog: {schema}"
);
let missing = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy transform unprefixed",
client.call_tool(
&cx,
PUBLIC_HTTP_TRANSFORMED_TOOL_NAME,
json!({"query": "alpha"}),
),
)
.await;
assert!(
missing.is_err(),
"changing only the missing prefix must not reach the unprefixed transformed tool: {missing:?}"
);
let looked_up = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy transform tools/call",
client.call_tool(&cx, &prefixed, json!({"query": "alpha"})),
)
.await
.expect(
"exact-2024 as_proxy WebSocket must forward the renamed argument onto the live transformed upstream",
);
assert_eq!(
legacy_http_tool_text(&looked_up).as_deref(),
Some("tool:alpha"),
"the rewritten argument must reach the parent handler through exact-2024 as_proxy WebSocket: {looked_up:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_legacy_as_proxy_include_tags_filters_prefixed_tools() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket as_proxy tags runtime installs an ambient context",
);
let tags_upstream = spawn_legacy_as_proxy_timeout_upstream();
let plan = ClientProtocolPlan::http(
ProtocolPolicy::LegacyOnly,
None,
Some(public_http_target(tags_upstream.address(), "/sse")),
Some(public_http_target(tags_upstream.address(), "/messages")),
"e2e-legacy-ws-proxy-tags-gateway".to_owned(),
"e2e-legacy-ws-proxy-tags-gateway".to_owned(),
"legacy-http-sse".to_owned(),
1,
1,
1,
)
.expect("legacy as_proxy WebSocket tags gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-legacy-ws-tags-upstream",
"native-h1:e2e-legacy-ws-tags-upstream",
1,
plan,
ClientInfo {
name: "e2e-legacy-ws-proxy-tags".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect(
"live exact-2024 HTTP tags proxy upstream must connect from the WebSocket runtime",
);
let catalog = proxy
.catalog_typed()
.expect("live exact-2024 HTTP tags proxy catalog is typed");
let server = legacy_2024::ServerBuilder::new("e2e-legacy-ws-tags-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("legacy as_proxy_typed tags install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket as_proxy tags must bind");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket as_proxy tags publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket as_proxy tags serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy tags handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("exact-2024 as_proxy WebSocket tags must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy tags initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-as-proxy-tags", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates as_proxy tags over bind_websocket");
let prefixed_cursor = format!("child/{PUBLIC_HTTP_TOOL_NAME}");
let prefixed_fast = format!("child/{PUBLIC_HTTP_FAST_TOOL_NAME}");
let cursor = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy includeTags cursor",
client.list_tools_page(
&cx,
legacy_2024::ListToolsParams {
include_tags: Some(vec!["cursor".to_owned()]),
..legacy_2024::ListToolsParams::default()
},
),
)
.await
.expect("exact-2024 as_proxy WebSocket must admit a tagged tools/list");
assert!(
cursor.tools.iter().any(|tool| tool.name == prefixed_cursor)
&& cursor
.tools
.iter()
.any(|tool| tool.tags.iter().any(|tag| tag == "cursor")),
"includeTags cursor must retain the prefixed value tool and its cursor tag over WebSocket: {cursor:?}"
);
assert!(
!cursor.tools.iter().any(|tool| tool.name == prefixed_fast),
"includeTags cursor must omit the untagged prefixed fast tool over WebSocket: {cursor:?}"
);
assert!(
!cursor.tools.iter().any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME),
"a nonempty as_proxy prefix must not keep the unprefixed value tool under includeTags: {cursor:?}"
);
let excluded = websocket_client_bounded(
&cx,
"live exact-2024 as_proxy excludeTags cursor",
client.list_tools_page(
&cx,
legacy_2024::ListToolsParams {
exclude_tags: Some(vec!["cursor".to_owned()]),
..legacy_2024::ListToolsParams::default()
},
),
)
.await
.expect("changing only excludeTags must still list the prefixed HTTP catalog over WebSocket");
assert!(
!excluded.tools.iter().any(|tool| tool.name == prefixed_cursor),
"excludeTags cursor must omit the prefixed value tool over WebSocket: {excluded:?}"
);
assert!(
excluded.tools.iter().any(|tool| tool.name == prefixed_fast),
"excludeTags cursor must keep the untagged prefixed fast tool over WebSocket: {excluded:?}"
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
tags_upstream.shutdown();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_transformed_tool_renames_argument_and_keeps_parent_unknown()
{
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket transform runtime installs an ambient context");
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-transform", "1.0.0")
.tool(
transform::TransformedTool::from_tool(PublicHttpValue)
.name(PUBLIC_HTTP_TRANSFORMED_TOOL_NAME)
.rename_arg("value", "query")
.build(),
)
.build();
let bound = server.bind_websocket(&cx, "127.0.0.1:0").await.expect(
"public LegacyOnly bind_websocket transform must bind a localhost listener",
);
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket transform publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public LegacyOnly bind_websocket transform serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 transform handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket transform must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 transform initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-transform", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates a transformed tool over bind_websocket",
);
let listed = websocket_client_bounded(
&cx,
"live exact-2024 transformed tools/list",
client.list_tools(&cx),
)
.await
.expect("live exact-2024 WebSocket must list the renamed tool");
let renamed = listed
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_TRANSFORMED_TOOL_NAME)
.unwrap_or_else(|| {
panic!("the transformed catalog must advertise the new name: {listed:?}")
});
let schema = serde_json::to_string(&renamed.input_schema)
.expect("the renamed input schema serializes");
assert!(
schema.contains("\"query\""),
"the transformed schema must advertise the renamed argument: {schema}"
);
assert!(
!schema.contains("\"value\""),
"the transformed schema must drop the parent argument name: {schema}"
);
let called = websocket_client_bounded(
&cx,
"live exact-2024 renamed tools/call",
client.call_tool(
&cx,
PUBLIC_HTTP_TRANSFORMED_TOOL_NAME,
json!({"query": "alpha"}),
),
)
.await
.expect("the renamed argument must reach the parent handler");
assert_eq!(
legacy_http_tool_text(&called).as_deref(),
Some("tool:alpha"),
"rename_arg must rewrite query back to the parent value: {called:?}"
);
let original = websocket_client_bounded(
&cx,
"live exact-2024 parent name after rename",
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect_err("the parent tool name must stay unknown after rename");
let original = format!("{original:?}");
assert!(
original.contains("not found")
|| original.contains("MethodNotFound")
|| original.contains("InvalidParams")
|| original.contains("ToolNotFound"),
"changing only the advertised name must refuse the parent tool: {original}"
);
websocket_client_bounded(&cx, "live exact-2024 transform close", client.close(&cx))
.await
.expect("the exact-2024 transform WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_rate_limit_refuses_second_same_method_and_admits_another() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket rate-limit runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-rate-limit", "1.0.0")
.middleware(rate_limiting::RateLimitingMiddleware::new(1.0e-300).burst_capacity(1))
.tool(PublicHttpTouchTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket rate-limit must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket rate-limit publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket rate-limit serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 rate-limit handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket rate-limit must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 rate-limit initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-rate-limit", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates rate limiting over bind_websocket");
let first = websocket_client_bounded(
&cx,
"live exact-2024 first rate-limited tools/call",
client.call_tool(&cx, PUBLIC_HTTP_TOUCH_TOOL_NAME, json!({})),
)
.await
.expect("the first exact-2024 WebSocket tools/call must be admitted by the live rate limiter");
let first_text = legacy_http_tool_text(&first);
assert!(
first_text.as_deref() == Some("notified") || first_text.as_deref() == Some("silent"),
"the first live tools/call must reach the touch handler: {first:?}"
);
let limited = websocket_client_bounded(
&cx,
"live exact-2024 second rate-limited tools/call",
client.call_tool(&cx, PUBLIC_HTTP_TOUCH_TOOL_NAME, json!({})),
)
.await;
let limited = match limited {
Ok(result) => {
assert!(
result.is_error,
"a second tools/call must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
limited.contains("Rate limit exceeded"),
"the refused second exact-2024 WebSocket tools/call must keep the rate-limit error: {limited}"
);
let listed = websocket_client_bounded(
&cx,
"live exact-2024 tools/list after rate limit",
client.list_tools(&cx),
)
.await
.expect("changing only the method must still be admitted by the live rate limiter");
assert!(
listed
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOUCH_TOOL_NAME),
"tools/list must still see the live tool after a rate-limited tools/call: {listed:?}"
);
websocket_client_bounded(&cx, "live exact-2024 rate-limit close", client.close(&cx))
.await
.expect("the exact-2024 rate-limit WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_cancel_stops_in_flight_tools_call_and_peer_stays_admitted()
{
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket cancel runtime installs an ambient context",
);
let started = Arc::new(AtomicBool::new(false));
let observed_cancellation = Arc::new(AtomicBool::new(false));
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-cancel", "1.0.0")
.tool(PublicHttpLegacyWaitTool {
started: Arc::clone(&started),
observed_cancellation: Arc::clone(&observed_cancellation),
})
.tool(PublicHttpFastTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket cancel must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket cancel publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket cancel serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 cancel handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket cancel must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 cancel initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-cancel", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates cancellation over bind_websocket");
let cancellation = McpRequestCancellation::new();
let cancel_flag = cancellation.clone();
let started_watch = Arc::clone(&started);
let cancel_scope = cx.scope();
let _canceller = cx
.spawn_in(&cancel_scope, move |task_cx| async move {
for _ in 0..500 {
if started_watch.load(Ordering::Acquire) {
cancel_flag.cancel();
return;
}
asupersync::time::sleep(task_cx.now(), Duration::from_millis(10)).await;
}
})
.expect("the exact-2024 WebSocket cancel watcher must be admitted");
let cancelled = websocket_client_bounded(
&cx,
"live exact-2024 cancelled tools/call",
client.call_tool_with_cancellation(
&cx,
&cancellation,
PUBLIC_HTTP_LEGACY_WAIT_TOOL_NAME,
json!({}),
),
)
.await;
let cancelled = match cancelled {
Ok(result) => panic!(
"a cancelled exact-2024 WebSocket tools/call must not return a result: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
cancelled.contains("cancelled") || cancelled.contains("Cancelled"),
"the cancelled exact-2024 WebSocket tools/call must stay request-cancelled: {cancelled}"
);
assert!(
!cancelled.contains("waited"),
"a cancelled wait tool must not publish its completion text: {cancelled}"
);
let observe_deadline = Instant::now() + HTTP_OPERATION_BOUND;
while !observed_cancellation.load(Ordering::Acquire)
&& Instant::now() < observe_deadline
{
asupersync::time::sleep(cx.now(), Duration::from_millis(10)).await;
}
assert!(
observed_cancellation.load(Ordering::Acquire),
"notifications/cancelled must reach the in-flight exact-2024 wait handler"
);
websocket_client_bounded(&cx, "live exact-2024 cancel close", client.close(&cx))
.await
.expect("the cancelled exact-2024 WebSocket client closes after the live proof");
drop(client);
let peer_transport = websocket_client_bounded(
&cx,
"live exact-2024 cancel peer handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("a peer exact-2024 WebSocket must still upgrade after cancellation");
let mut peer = websocket_client_bounded(
&cx,
"live exact-2024 cancel peer initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-cancel-peer", "1.0.0")
.connect_websocket_with_cx(&cx, peer_transport),
)
.await
.expect("a peer exact-2024 WebSocket must still negotiate after cancellation");
let peer_called = websocket_client_bounded(
&cx,
"live exact-2024 cancel peer tools/call",
peer.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.await
.expect("a peer tools/call must stay admitted after cancellation");
assert_eq!(
legacy_http_tool_text(&peer_called).as_deref(),
Some("fast"),
"changing only the cancelled request must not starve the peer: {peer_called:?}"
);
websocket_client_bounded(&cx, "live exact-2024 cancel peer close", peer.close(&cx))
.await
.expect("the peer exact-2024 WebSocket client closes after the live proof");
drop(peer);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_transformed_tool_hides_argument_and_injects_default() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket hide-arg runtime installs an ambient context");
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-hide", "1.0.0")
.tool(
transform::TransformedTool::from_tool(PublicHttpValue)
.name(PUBLIC_HTTP_HIDDEN_TOOL_NAME)
.hide_arg("value", "hidden-default")
.build(),
)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket hide-arg must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket hide-arg publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public LegacyOnly bind_websocket hide-arg serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 hide-arg handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket hide-arg must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 hide-arg initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-hide", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates hide-arg over bind_websocket");
let listed = websocket_client_bounded(
&cx,
"live exact-2024 hide-arg tools/list",
client.list_tools(&cx),
)
.await
.expect("live exact-2024 WebSocket must list the hide-arg tool");
let hidden = listed
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_HIDDEN_TOOL_NAME)
.unwrap_or_else(|| {
panic!("the hide-arg catalog must advertise the new name: {listed:?}")
});
let schema = serde_json::to_string(&hidden.input_schema)
.expect("the hide-arg input schema serializes");
assert!(
!schema.contains("\"value\""),
"the hide-arg schema must drop the hidden argument: {schema}"
);
let injected = websocket_client_bounded(
&cx,
"live exact-2024 hide-arg tools/call",
client.call_tool(&cx, PUBLIC_HTTP_HIDDEN_TOOL_NAME, json!({})),
)
.await
.expect("the hide-arg tools/call must inject the configured default");
assert_eq!(
legacy_http_tool_text(&injected).as_deref(),
Some("tool:hidden-default"),
"the hidden default must reach the parent handler: {injected:?}"
);
let original = websocket_client_bounded(
&cx,
"live exact-2024 parent name after hide-arg",
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect_err("the parent tool name must stay unknown after hide-arg");
let original = format!("{original:?}");
assert!(
original.contains("not found")
|| original.contains("MethodNotFound")
|| original.contains("InvalidParams")
|| original.contains("ToolNotFound")
|| original.contains("Unknown tool"),
"changing only the advertised name must refuse the parent tool: {original}"
);
websocket_client_bounded(&cx, "live exact-2024 hide-arg close", client.close(&cx))
.await
.expect("the exact-2024 hide-arg WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_response_cache_hits_same_allowlisted_call_and_misses_changed_args()
{
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket cache runtime installs an ambient context");
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-cache", "1.0.0")
.middleware(
caching::ResponseCachingMiddleware::new()
.include_tools(vec![PUBLIC_HTTP_TOOL_NAME.to_owned()]),
)
.tool(CountingPublicHttpValue {
counters: Arc::clone(&handler_calls),
})
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket cache must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket cache publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public LegacyOnly bind_websocket cache serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 cache handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket cache must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 cache initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-cache", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates response caching over bind_websocket");
let first = websocket_client_bounded(
&cx,
"live exact-2024 first cached tools/call",
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect("the first allowlisted tools/call must miss and reach the handler");
assert_eq!(
legacy_http_tool_text(&first).as_deref(),
Some("tool:alpha"),
"the first live tools/call must return the handler result: {first:?}"
);
assert_eq!(
handler_calls.snapshot().tool,
1,
"the first allowlisted tools/call must invoke the counting handler"
);
let cached = websocket_client_bounded(
&cx,
"live exact-2024 cached tools/call",
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect("the second identical tools/call must hit the live cache");
assert_eq!(
legacy_http_tool_text(&cached).as_deref(),
Some("tool:alpha"),
"the cached live tools/call must retain the first handler result: {cached:?}"
);
assert_eq!(
handler_calls.snapshot().tool,
1,
"an identical allowlisted tools/call must not invoke the handler again"
);
let missed = websocket_client_bounded(
&cx,
"live exact-2024 cache miss tools/call",
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "beta"})),
)
.await
.expect("changing only the arguments must miss the live cache");
assert_eq!(
legacy_http_tool_text(&missed).as_deref(),
Some("tool:beta"),
"the cache-miss tools/call must reach the handler: {missed:?}"
);
assert_eq!(
handler_calls.snapshot().tool,
2,
"changing only the arguments must increment the counting handler"
);
websocket_client_bounded(&cx, "live exact-2024 cache close", client.close(&cx))
.await
.expect("the exact-2024 cache WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_on_duplicate_error_keeps_first_and_replace_installs_second()
{
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket on_duplicate runtime installs an ambient context",
);
let keep = legacy_2024::ServerBuilder::new("facade-ws-legacy-dup-error", "1.0.0")
.on_duplicate(DuplicateBehavior::Error)
.tool(PublicHttpValue)
.tool(ReplacedPublicHttpValue)
.build();
let keep_bound = keep
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket duplicate-error must bind");
let keep_address = keep_bound
.local_addr()
.expect("public LegacyOnly bind_websocket duplicate-error publishes its address");
let keep_scope = cx.scope();
let keep_listener = cx
.spawn_in(&keep_scope, move |serve_cx| async move {
keep_bound.serve(&serve_cx).await
})
.expect("public LegacyOnly bind_websocket duplicate-error serve must be admitted");
let keep_transport = websocket_client_bounded(
&cx,
"live exact-2024 duplicate-error handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{keep_address}/mcp")),
)
.await
.expect("duplicate-error WebSocket must complete RFC 6455 upgrade");
let mut keep_client = websocket_client_bounded(
&cx,
"live exact-2024 duplicate-error initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-dup-error", "1.0.0")
.connect_websocket_with_cx(&cx, keep_transport),
)
.await
.expect("duplicate-error WebSocket must negotiate LegacyOnly");
let kept = websocket_client_bounded(
&cx,
"live exact-2024 duplicate-error tools/call",
keep_client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect("Error must keep the first registration callable");
assert_eq!(
legacy_http_tool_text(&kept).as_deref(),
Some("tool:alpha"),
"on_duplicate Error must keep the first handler: {kept:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 duplicate-error close",
keep_client.close(&cx),
)
.await
.expect("the duplicate-error WebSocket client closes");
drop(keep_client);
keep_listener.abort();
let replace = legacy_2024::ServerBuilder::new("facade-ws-legacy-dup-replace", "1.0.0")
.on_duplicate(DuplicateBehavior::Replace)
.tool(PublicHttpValue)
.tool(ReplacedPublicHttpValue)
.build();
let replace_bound = replace
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket duplicate-replace must bind");
let replace_address = replace_bound
.local_addr()
.expect("public LegacyOnly bind_websocket duplicate-replace publishes its address");
let replace_scope = cx.scope();
let replace_listener = cx
.spawn_in(&replace_scope, move |serve_cx| async move {
replace_bound.serve(&serve_cx).await
})
.expect(
"public LegacyOnly bind_websocket duplicate-replace serve must be admitted",
);
let replace_transport = websocket_client_bounded(
&cx,
"live exact-2024 duplicate-replace handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{replace_address}/mcp")),
)
.await
.expect("duplicate-replace WebSocket must complete RFC 6455 upgrade");
let mut replace_client = websocket_client_bounded(
&cx,
"live exact-2024 duplicate-replace initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-dup-replace", "1.0.0")
.connect_websocket_with_cx(&cx, replace_transport),
)
.await
.expect("duplicate-replace WebSocket must negotiate LegacyOnly");
let replaced = websocket_client_bounded(
&cx,
"live exact-2024 duplicate-replace tools/call",
replace_client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect("Replace must install the second registration");
assert_eq!(
legacy_http_tool_text(&replaced).as_deref(),
Some("replaced:alpha"),
"changing only on_duplicate to Replace must reach the second handler: {replaced:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 duplicate-replace close",
replace_client.close(&cx),
)
.await
.expect("the duplicate-replace WebSocket client closes");
drop(replace_client);
cx.set_cancel_requested(true);
replace_listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_custom_token_refuses_wrong_and_commits_subject() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket custom-auth runtime installs an ambient context",
);
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-custom-auth", "1.0.0")
.auth_provider(TokenAuthProvider::new(PublicHttpCustomVerifier))
.tool(PublicHttpAuthSubject {
counters: Arc::clone(&handler_calls),
})
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket custom auth must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket custom auth publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket custom auth serve must be admitted");
let (missing, _missing_stream) = websocket_upgrade_status(&cx, address, None).await;
assert!(
missing.starts_with(b"HTTP/1.1 401"),
"omitting Authorization must refuse the custom-token WebSocket upgrade: {}",
String::from_utf8_lossy(&missing)
);
assert!(
String::from_utf8_lossy(&missing)
.to_ascii_lowercase()
.contains("www-authenticate"),
"the missing-token custom WebSocket upgrade must challenge Bearer: {}",
String::from_utf8_lossy(&missing)
);
assert_eq!(
handler_calls.snapshot().tool,
0,
"missing Authorization must not invoke the custom-verified handler"
);
let (wrong, _wrong_stream) =
websocket_upgrade_status(&cx, address, Some("Bearer alpha")).await;
assert!(
wrong.starts_with(b"HTTP/1.1 401"),
"a static token must refuse the custom-token WebSocket upgrade: {}",
String::from_utf8_lossy(&wrong)
);
assert_eq!(
handler_calls.snapshot().tool,
0,
"changing only the custom verifier token must not invoke the handler"
);
let matching = format!("Bearer {PUBLIC_HTTP_CUSTOM_AUTH_TOKEN}");
let (accepted, stream) =
websocket_upgrade_status(&cx, address, Some(matching.as_str())).await;
assert!(
accepted.starts_with(b"HTTP/1.1 101"),
"the matching custom verifier token must complete RFC 6455 upgrade: {}",
String::from_utf8_lossy(&accepted)
);
let transport = AsyncWsClientTransport::from_upgraded(stream);
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 custom-auth initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-custom-auth", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("an admitted custom-token WebSocket upgrade must negotiate LegacyOnly");
let admitted = websocket_client_bounded(
&cx,
"live exact-2024 custom-auth tools/call",
client.call_tool(&cx, PUBLIC_HTTP_AUTH_TOOL_NAME, json!({})),
)
.await
.expect("the matching custom verifier token must reach the authenticated handler");
let expected_subject = format!("subject:{PUBLIC_HTTP_CUSTOM_AUTH_SUBJECT}");
assert_eq!(
legacy_http_tool_text(&admitted).as_deref(),
Some(expected_subject.as_str()),
"admitted exact-2024 WebSocket must commit the custom token subject into ctx.auth(): {admitted:?}"
);
assert_eq!(
handler_calls.snapshot().tool,
1,
"only the matching custom verifier token may invoke the handler"
);
websocket_client_bounded(&cx, "live exact-2024 custom-auth close", client.close(&cx))
.await
.expect("the exact-2024 custom-auth WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_tools_list_cursor_continues_and_rejects_query_or_kind_change()
{
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket cursor runtime installs an ambient context");
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-cursor", "1.0.0")
.list_page_size(1)
.tool(PublicHttpValue)
.tool(PublicHttpCursorSecondary)
.tool(PublicHttpCursorOther)
.resource(PublicHttpSnapshotResource)
.resource(PublicHttpCursorResourceAResource)
.resource(PublicHttpCursorResourceBResource)
.prompt(PublicHttpCursorPromptAPrompt)
.prompt(PublicHttpCursorPromptBPrompt)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket cursor must bind a localhost listener");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket cursor publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public LegacyOnly bind_websocket cursor serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 cursor handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket cursor must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 cursor initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-cursor", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates catalog cursors over bind_websocket");
let first = websocket_client_bounded(
&cx,
"live exact-2024 first cursor tools/list",
client.list_tools_page(
&cx,
legacy_2024::ListToolsParams {
cursor: None,
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.await
.expect("live exact-2024 WebSocket must page the cursor-tagged tools");
assert_eq!(
first.tools.len(),
1,
"page size 1 must create a real continuation: {first:?}"
);
assert_eq!(
first.tools[0].name, PUBLIC_HTTP_TOOL_NAME,
"the first cursor page must retain the primary tagged tool: {first:?}"
);
let cursor = first
.next_cursor
.clone()
.expect("the first filtered page must carry an opaque cursor");
let second = websocket_client_bounded(
&cx,
"live exact-2024 cursor continuation tools/list",
client.list_tools_page(
&cx,
legacy_2024::ListToolsParams {
cursor: Some(cursor.clone()),
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.await
.expect("the exact cursor/query continuation must reach the second live page");
assert_eq!(
second.tools.len(),
1,
"the continuation must retain one remaining tagged tool: {second:?}"
);
assert_eq!(
second.tools[0].name, PUBLIC_HTTP_CURSOR_SECONDARY_TOOL_NAME,
"the continuation must retain the second tagged tool: {second:?}"
);
assert!(
second.next_cursor.is_none(),
"the last cursor page must not invent another continuation: {second:?}"
);
let query_rejection = websocket_client_bounded(
&cx,
"live exact-2024 cursor query mismatch",
client.list_tools_page(
&cx,
legacy_2024::ListToolsParams {
cursor: Some(cursor.clone()),
include_tags: Some(vec!["other".to_owned()]),
exclude_tags: None,
},
),
)
.await
.expect_err("changing only includeTags must reject the cursor before page projection");
let query_rejection = format!("{query_rejection:?}");
assert!(
query_rejection.contains("InvalidParams") || query_rejection.contains("invalid"),
"a query-mismatched cursor must stay InvalidParams: {query_rejection}"
);
let kind_rejection = websocket_client_bounded(
&cx,
"live exact-2024 cursor kind mismatch",
client.list_resources_page(
&cx,
legacy_2024::ListResourcesParams {
cursor: Some(cursor),
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.await
.expect_err("changing only the catalog kind must reject the tools cursor");
let kind_rejection = format!("{kind_rejection:?}");
assert!(
kind_rejection.contains("InvalidParams") || kind_rejection.contains("invalid"),
"a tools cursor must not select a resources page: {kind_rejection}"
);
websocket_client_bounded(&cx, "live exact-2024 cursor close", client.close(&cx))
.await
.expect("the exact-2024 cursor WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_resources_and_prompts_list_cursor_continues() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket catalog-cursor runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-catalog-cursor", "1.0.0")
.list_page_size(1)
.tool(PublicHttpValue)
.tool(PublicHttpCursorSecondary)
.resource(PublicHttpCursorResourceAResource)
.resource(PublicHttpCursorResourceBResource)
.prompt(PublicHttpCursorPromptAPrompt)
.prompt(PublicHttpCursorPromptBPrompt)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket catalog-cursor must bind");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket catalog-cursor publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket catalog-cursor serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 catalog-cursor handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("catalog-cursor WebSocket must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 catalog-cursor initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-catalog-cursor", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("catalog-cursor WebSocket must negotiate LegacyOnly");
let first_resources = websocket_client_bounded(
&cx,
"live exact-2024 first cursor resources/list",
client.list_resources_page(
&cx,
legacy_2024::ListResourcesParams {
cursor: None,
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.await
.expect("live exact-2024 WebSocket must page the cursor-tagged resources");
assert_eq!(
first_resources.resources.len(),
1,
"page size 1 must create a real resource continuation: {first_resources:?}"
);
let first_resource_uri = first_resources.resources[0].uri.clone();
assert!(
first_resource_uri == PUBLIC_HTTP_CURSOR_RESOURCE_A_URI
|| first_resource_uri == PUBLIC_HTTP_CURSOR_RESOURCE_B_URI,
"the first resource page must retain a cursor-tagged URI: {first_resources:?}"
);
let resource_cursor = first_resources
.next_cursor
.clone()
.expect("the first filtered resource page must carry an opaque cursor");
let second_resources = websocket_client_bounded(
&cx,
"live exact-2024 cursor continuation resources/list",
client.list_resources_page(
&cx,
legacy_2024::ListResourcesParams {
cursor: Some(resource_cursor.clone()),
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.await
.expect("the exact resource cursor continuation must reach the second live page");
assert_eq!(
second_resources.resources.len(),
1,
"the resource continuation must retain one remaining tagged URI: {second_resources:?}"
);
let second_resource_uri = second_resources.resources[0].uri.as_str();
assert_ne!(
second_resource_uri, first_resource_uri,
"the resource continuation must retain the other tagged URI: {second_resources:?}"
);
assert!(
second_resources.next_cursor.is_none(),
"the last resource cursor page must not invent another continuation: {second_resources:?}"
);
let resource_kind_rejection = websocket_client_bounded(
&cx,
"live exact-2024 resource cursor kind mismatch",
client.list_prompts_page(
&cx,
legacy_2024::ListPromptsParams {
cursor: Some(resource_cursor),
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.await
.expect_err("changing only the catalog kind must reject the resources cursor");
let resource_kind_rejection = format!("{resource_kind_rejection:?}");
assert!(
resource_kind_rejection.contains("InvalidParams")
|| resource_kind_rejection.contains("invalid"),
"a resources cursor must not select a prompts page: {resource_kind_rejection}"
);
let first_prompts = websocket_client_bounded(
&cx,
"live exact-2024 first cursor prompts/list",
client.list_prompts_page(
&cx,
legacy_2024::ListPromptsParams {
cursor: None,
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.await
.expect("live exact-2024 WebSocket must page the cursor-tagged prompts");
assert_eq!(
first_prompts.prompts.len(),
1,
"page size 1 must create a real prompt continuation: {first_prompts:?}"
);
let first_prompt_name = first_prompts.prompts[0].name.clone();
let prompt_cursor = first_prompts
.next_cursor
.clone()
.expect("the first filtered prompt page must carry an opaque cursor");
let second_prompts = websocket_client_bounded(
&cx,
"live exact-2024 cursor continuation prompts/list",
client.list_prompts_page(
&cx,
legacy_2024::ListPromptsParams {
cursor: Some(prompt_cursor.clone()),
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.await
.expect("the exact prompt cursor continuation must reach the second live page");
assert_eq!(
second_prompts.prompts.len(),
1,
"the prompt continuation must retain one remaining tagged name: {second_prompts:?}"
);
assert_ne!(
second_prompts.prompts[0].name, first_prompt_name,
"the prompt continuation must retain the other tagged name: {second_prompts:?}"
);
assert!(
second_prompts.next_cursor.is_none(),
"the last prompt cursor page must not invent another continuation: {second_prompts:?}"
);
let prompt_kind_rejection = websocket_client_bounded(
&cx,
"live exact-2024 prompt cursor kind mismatch",
client.list_tools_page(
&cx,
legacy_2024::ListToolsParams {
cursor: Some(prompt_cursor),
include_tags: Some(vec!["cursor".to_owned()]),
exclude_tags: None,
},
),
)
.await
.expect_err("changing only the catalog kind must reject the prompts cursor");
let prompt_kind_rejection = format!("{prompt_kind_rejection:?}");
assert!(
prompt_kind_rejection.contains("InvalidParams")
|| prompt_kind_rejection.contains("invalid"),
"a prompts cursor must not select a tools page: {prompt_kind_rejection}"
);
websocket_client_bounded(&cx, "live exact-2024 catalog-cursor close", client.close(&cx))
.await
.expect("the exact-2024 catalog-cursor WebSocket client closes");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
fn modern_ws_completion_params(name: &str) -> modern::CompletionParams {
modern::CompletionParams {
reference: modern::CompletionReference::PromptWithTitle {
name: name.to_owned(),
title: "Public HTTP E2E Prompt".to_owned(),
},
argument: modern::FinalCompletionArgument {
name: "subject".to_owned(),
value: "cross-era".to_owned(),
},
context: Some(modern::FinalCompletionContext {
arguments: Some(BTreeMap::from([(
"region".to_owned(),
"us-east-1".to_owned(),
)])),
}),
}
}
#[test]
fn e2e_public_websocket_bind_cancel_stops_in_flight_tools_call_and_peer_stays_admitted() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket cancel runtime installs an ambient context");
let started = Arc::new(AtomicBool::new(false));
let observed_cancellation = Arc::new(AtomicBool::new(false));
let server = modern::ServerBuilder::new("facade-ws-cancel", "1.0.0")
.tool(PublicHttpLegacyWaitTool {
started: Arc::clone(&started),
observed_cancellation: Arc::clone(&observed_cancellation),
})
.tool(PublicHttpFastTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket cancel must bind a localhost listener");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket cancel publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public ModernOnly bind_websocket cancel serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern cancel handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket cancel must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern cancel initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-cancel", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates cancellation over bind_websocket");
let cancellation = McpRequestCancellation::new();
let cancel_flag = cancellation.clone();
let started_watch = Arc::clone(&started);
let cancel_scope = cx.scope();
let _canceller = cx
.spawn_in(&cancel_scope, move |task_cx| async move {
for _ in 0..500 {
if started_watch.load(Ordering::Acquire) {
cancel_flag.cancel();
return;
}
asupersync::time::sleep(task_cx.now(), Duration::from_millis(10)).await;
}
})
.expect("the modern WebSocket cancel watcher must be admitted");
let cancelled = websocket_client_bounded(
&cx,
"live modern cancelled tools/call",
client.call_tool_with_cancellation(
&cx,
&cancellation,
PUBLIC_HTTP_LEGACY_WAIT_TOOL_NAME,
json!({}),
),
)
.await;
let cancelled = match cancelled {
Ok(result) => panic!(
"a cancelled modern WebSocket tools/call must not return a result: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
cancelled.contains("cancelled") || cancelled.contains("Cancelled"),
"the cancelled modern WebSocket tools/call must stay request-cancelled: {cancelled}"
);
assert!(
!cancelled.contains("waited"),
"a cancelled wait tool must not publish its completion text: {cancelled}"
);
let observe_deadline = Instant::now() + HTTP_OPERATION_BOUND;
while !observed_cancellation.load(Ordering::Acquire)
&& Instant::now() < observe_deadline
{
asupersync::time::sleep(cx.now(), Duration::from_millis(10)).await;
}
assert!(
observed_cancellation.load(Ordering::Acquire),
"notifications/cancelled must reach the in-flight modern wait handler"
);
websocket_client_bounded(&cx, "live modern cancel close", client.close(&cx))
.await
.expect("the cancelled modern WebSocket client closes after the live proof");
drop(client);
let peer_transport = websocket_client_bounded(
&cx,
"live modern cancel peer handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("a peer modern WebSocket must still upgrade after cancellation");
let mut peer = websocket_client_bounded(
&cx,
"live modern cancel peer initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-cancel-peer", "1.0.0")
.connect_websocket_with_cx(&cx, peer_transport),
)
.await
.expect("a peer modern WebSocket must still negotiate after cancellation");
let peer_called = websocket_client_bounded(
&cx,
"live modern cancel peer tools/call",
peer.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.await
.expect("a peer tools/call must stay admitted after cancellation");
assert!(
peer_called.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"changing only the cancelled request must not starve the peer: {peer_called:?}"
);
websocket_client_bounded(&cx, "live modern cancel peer close", peer.close(&cx))
.await
.expect("the peer modern WebSocket client closes after the live proof");
drop(peer);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_complete_is_retained_and_peer_without_handler_is_refused() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket complete runtime installs an ambient context");
let advertised = modern::ServerBuilder::new("facade-ws-complete", "1.0.0")
.prompt(PublicHttpInstructionPrompt)
.completion_handler(CountingPublicHttpCompletion {
counters: Arc::new(PublicHttpHandlerCallCounters::default()),
})
.build();
let advertised_bound = advertised
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket complete must bind a localhost listener");
let advertised_address = advertised_bound
.local_addr()
.expect("public ModernOnly bind_websocket complete publishes its bound address");
let omitted = modern::ServerBuilder::new("facade-ws-complete-omitted", "1.0.0")
.prompt(PublicHttpInstructionPrompt)
.build();
let omitted_bound = omitted
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("the omitted-completion modern WebSocket server must bind");
let omitted_address = omitted_bound.local_addr().expect(
"the omitted-completion modern WebSocket server publishes its bound address",
);
let scope = cx.scope();
let advertised_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
advertised_bound.serve(&serve_cx).await
})
.expect("advertised modern complete serve must be admitted");
let omitted_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
omitted_bound.serve(&serve_cx).await
})
.expect("omitted modern complete serve must be admitted");
let advertised_transport = websocket_client_bounded(
&cx,
"live modern complete handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{advertised_address}/mcp")),
)
.await
.expect("advertised complete modern WebSocket must complete RFC 6455 upgrade");
let mut advertised_client = websocket_client_bounded(
&cx,
"live modern complete initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-complete", "1.0.0")
.connect_websocket_with_cx(&cx, advertised_transport),
)
.await
.expect("the advertised complete modern WebSocket peer must initialize");
let advertised_discovery = advertised_client
.session()
.server_discovery()
.expect("a modern WebSocket handshake retains server/discover");
let advertised_caps = serde_json::to_value(advertised_discovery.capabilities())
.expect("modern discovery capabilities serialize");
assert_eq!(
advertised_caps.get("completions"),
Some(&json!({})),
"installing a modern completion handler must advertise completions: {advertised_caps:?}"
);
let omitted_transport = websocket_client_bounded(
&cx,
"live modern complete omitted handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{omitted_address}/mcp")),
)
.await
.expect("omitted complete modern WebSocket must complete RFC 6455 upgrade");
let mut omitted_client = websocket_client_bounded(
&cx,
"live modern complete omitted initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-complete-omitted", "1.0.0")
.connect_websocket_with_cx(&cx, omitted_transport),
)
.await
.expect("the omitted complete modern WebSocket peer must initialize");
let omitted_discovery = omitted_client
.session()
.server_discovery()
.expect("the omitted-completion modern handshake retains server/discover");
let omitted_caps = serde_json::to_value(omitted_discovery.capabilities())
.expect("omitted discovery capabilities serialize");
assert!(
omitted_caps.get("completions").is_none(),
"changing only the missing completion handler must omit completions: {omitted_caps:?}"
);
let completed = websocket_client_bounded(
&cx,
"live modern WebSocket completion/complete",
advertised_client
.complete(&cx, modern_ws_completion_params(PUBLIC_HTTP_PROMPT_NAME)),
)
.await
.expect("live modern WebSocket must complete when completions are advertised");
assert_eq!(
completed.completion.values,
vec![PUBLIC_HTTP_COMPLETION_VALUE.to_owned()],
"the advertised modern completion provider must retain its values: {completed:?}"
);
assert!(
advertised_client.take_progress_notifications().is_empty(),
"without a progressToken the modern WebSocket completion must stay silent"
);
let marker = modern::ProgressMarker::from("ws-modern-completion-progress");
let progressed = websocket_client_bounded(
&cx,
"live modern WebSocket completion/complete with token",
advertised_client.complete_with_progress_marker(
&cx,
modern_ws_completion_params(PUBLIC_HTTP_PROMPT_NAME),
marker.clone(),
),
)
.await
.expect("a progressToken must not prevent the modern WebSocket completion from completing");
assert_eq!(
progressed.completion.values,
vec![PUBLIC_HTTP_COMPLETION_VALUE.to_owned()],
"the progress-marked modern WebSocket completion must retain its values: {progressed:?}"
);
let progress = advertised_client.take_progress_notifications();
assert!(
progress.iter().any(|notification| {
notification.progress_token == marker
&& notification.message.as_deref() == Some("completion-halfway")
}),
"live modern WebSocket must retain completion notifications/progress after a progressToken: {progress:?}"
);
websocket_client_bounded(
&cx,
"live modern WebSocket complete without handler",
omitted_client.complete(&cx, modern_ws_completion_params(PUBLIC_HTTP_PROMPT_NAME)),
)
.await
.expect_err("changing only the missing completion handler must refuse complete");
websocket_client_bounded(
&cx,
"live modern complete advertised close",
advertised_client.close(&cx),
)
.await
.expect("the advertised complete modern WebSocket client closes after the live proof");
websocket_client_bounded(
&cx,
"live modern complete omitted close",
omitted_client.close(&cx),
)
.await
.expect(
"the omitted complete modern WebSocket client closes after the planted refusal",
);
drop(advertised_client);
drop(omitted_client);
cx.set_cancel_requested(true);
advertised_listener.abort();
omitted_listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_resource_template_completion_is_retained_and_unregistered_template_is_refused()
{
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket template-complete runtime installs an ambient context",
);
let server = modern::ServerBuilder::new("facade-ws-template-complete", "1.0.0")
.tool(PublicHttpFastTool)
.resource(PublicHttpCursorTemplateResource)
.resource(PublicHttpOtherTemplateResource)
.resource_template_completion_handler(
PUBLIC_HTTP_CURSOR_TEMPLATE,
PublicHttpTemplateCompletion,
)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect(
"public ModernOnly bind_websocket template complete must bind a localhost listener",
);
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket template complete publishes its bound address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("modern template-complete serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern template-complete handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("template-complete modern WebSocket must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern template-complete initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-template-complete", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the template-complete modern WebSocket peer must initialize");
let discovery = client
.session()
.server_discovery()
.expect("a modern WebSocket handshake retains server/discover");
let advertised_caps = serde_json::to_value(discovery.capabilities())
.expect("modern discovery capabilities serialize");
assert_eq!(
advertised_caps.get("completions"),
Some(&json!({})),
"installing a resource-template completion provider must advertise completions: {advertised_caps:?}"
);
let completed = websocket_client_bounded(
&cx,
"live modern template completion/complete",
client.complete(&cx, public_http_template_completion_params()),
)
.await
.expect("live bind_websocket must complete the registered resource template provider");
assert_eq!(
completed.completion.values,
vec![PUBLIC_HTTP_TEMPLATE_COMPLETION_VALUE.to_owned()],
"the modern WebSocket template provider must retain its values: {completed:?}"
);
let mut undeclared = public_http_template_completion_params();
undeclared.argument.name = "undeclared".to_owned();
websocket_client_bounded(
&cx,
"live modern undeclared template completion",
client.complete(&cx, undeclared),
)
.await
.expect_err("only an undeclared template variable is rejected");
let mut other_template = public_http_template_completion_params();
other_template.reference = modern::CompletionReference::Resource {
uri: PUBLIC_HTTP_OTHER_TEMPLATE.to_owned(),
};
websocket_client_bounded(
&cx,
"live modern unregistered template completion",
client.complete(&cx, other_template),
)
.await
.expect_err("changing only the template URI must refuse a missing provider");
let fast = websocket_client_bounded(
&cx,
"live modern fast tools/call after template completion",
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.await
.expect("changing only the method must still admit tools/call after template completion");
assert!(
fast.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"the fast peer tool must still complete after template completion: {fast:?}"
);
websocket_client_bounded(
&cx,
"live modern template-complete close",
client.close(&cx),
)
.await
.expect("the modern template-complete WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_composes_nested_prompt_and_refuses_missing_name() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket compose-prompt runtime installs an ambient context",
);
let server = modern::ServerBuilder::new("facade-ws-compose-prompt", "1.0.0")
.tool(PublicHttpPromptCompose)
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.prompt(PublicHttpFromPromptPrompt)
.resource(PublicHttpResourceComposesPromptResource)
.resource(PublicHttpResourceComposesMissingPromptResource)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket compose-prompt must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket compose-prompt publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("modern compose-prompt serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern compose-prompt handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("compose-prompt modern WebSocket must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern compose-prompt initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-compose-prompt", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the compose-prompt modern WebSocket peer must initialize");
let composed = websocket_client_bounded(
&cx,
"live modern compose_prompt",
client.call_tool(
&cx,
PUBLIC_HTTP_PROMPT_COMPOSE_TOOL_NAME,
json!({"subject": "alpha"}),
),
)
.await
.expect("live bind_websocket compose_prompt must nest the instruction prompt");
assert!(
composed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "compose-prompt:prompt:alpha",
_ => false,
}),
"compose_prompt must retain the nested prompt text: {composed:?}"
);
let missing = websocket_client_bounded(
&cx,
"live modern missing nested prompt",
client.call_tool(
&cx,
PUBLIC_HTTP_PROMPT_COMPOSE_TOOL_NAME,
json!({
"subject": "alpha",
"prompt": "public-http-e2e-missing",
}),
),
)
.await;
let missing = match missing {
Ok(result) if result.is_error => format!("{result:?}"),
Err(error) => format!("{error:?}"),
Ok(result) => {
panic!("changing only the nested prompt name must refuse: {result:?}")
}
};
assert!(
missing.contains("public-http-e2e-missing")
|| missing.contains("compose-nested-prompt")
|| missing.contains("not found"),
"the nested unknown prompt must stay a handler-visible refusal: {missing}"
);
let fast = websocket_client_bounded(
&cx,
"live modern fast tools/call after prompt compose",
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.await
.expect("changing only the method must still admit tools/call after prompt compose");
assert!(
fast.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"the fast peer tool must still complete after prompt compose: {fast:?}"
);
websocket_client_bounded(&cx, "live modern compose-prompt close", client.close(&cx))
.await
.expect("the modern compose-prompt WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_resource_and_prompt_compose_nested_prompt() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket catalog compose-prompt runtime installs an ambient context",
);
let server = modern::ServerBuilder::new("facade-ws-catalog-compose-prompt", "1.0.0")
.tool(PublicHttpFastTool)
.prompt(PublicHttpInstructionPrompt)
.prompt(PublicHttpFromPromptPrompt)
.resource(PublicHttpResourceComposesPromptResource)
.resource(PublicHttpResourceComposesMissingPromptResource)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket catalog compose-prompt must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket catalog compose-prompt publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("modern catalog compose-prompt serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern catalog compose-prompt handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("catalog compose-prompt modern WebSocket must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern catalog compose-prompt initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-catalog-compose-prompt", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the catalog compose-prompt modern WebSocket peer must initialize");
let resource = websocket_client_bounded(
&cx,
"live modern resource compose-prompt",
client.read_resource(&cx, PUBLIC_HTTP_PROMPT_COMPOSE_RESOURCE_URI),
)
.await
.expect("live bind_websocket resources/read must nest the instruction prompt");
assert!(
matches!(
resource.contents.as_slice(),
[EmbeddedResourceContents::Text { text, .. }]
if text == "compose-prompt:prompt:alpha"
),
"modern WebSocket resource ctx.get_prompt must retain the nested prompt text: {resource:?}"
);
let missing_resource = websocket_client_bounded(
&cx,
"live modern missing nested prompt from resources/read",
client.read_resource(&cx, PUBLIC_HTTP_PROMPT_COMPOSE_MISSING_RESOURCE_URI),
)
.await;
let missing_resource = match missing_resource {
Ok(result) => panic!(
"changing only the nested prompt name must refuse resources/read: {result:?}"
),
Err(error) => format!("{error:?}"),
};
assert!(
nested_prompt_refusal(&missing_resource),
"the nested unknown prompt must stay a handler-visible resource refusal: {missing_resource}"
);
let prompt = websocket_client_bounded(
&cx,
"live modern prompt compose-prompt",
client.get_prompt(
&cx,
PUBLIC_HTTP_FROM_PROMPT_NAME,
HashMap::from([("subject".to_owned(), "alpha".to_owned())]),
),
)
.await
.expect("live bind_websocket prompts/get must nest the instruction prompt");
assert!(
prompt.messages.iter().any(|message| match &message.content {
ContentBlock::Text { text, .. } => text == "compose-prompt:prompt:alpha",
_ => false,
}),
"modern WebSocket prompt ctx.get_prompt must retain the nested prompt text: {prompt:?}"
);
let missing_prompt = websocket_client_bounded(
&cx,
"live modern missing nested prompt from prompts/get",
client.get_prompt(
&cx,
PUBLIC_HTTP_FROM_PROMPT_NAME,
HashMap::from([
("subject".to_owned(), "alpha".to_owned()),
("prompt".to_owned(), "public-http-e2e-missing".to_owned()),
]),
),
)
.await;
let missing_prompt = match missing_prompt {
Ok(result) => format!("{result:?}"),
Err(error) => format!("{error:?}"),
};
assert!(
nested_prompt_refusal(&missing_prompt),
"the nested unknown prompt must stay a handler-visible prompt refusal: {missing_prompt}"
);
let fast = websocket_client_bounded(
&cx,
"live modern fast tools/call after catalog prompt compose",
client.call_tool(&cx, PUBLIC_HTTP_FAST_TOOL_NAME, json!({})),
)
.await
.expect("changing only the method must still admit tools/call after catalog prompt compose");
assert!(
fast.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "fast",
_ => false,
}),
"the fast modern WebSocket peer tool must still complete after catalog prompt compose: {fast:?}"
);
websocket_client_bounded(
&cx,
"live modern catalog compose-prompt close",
client.close(&cx),
)
.await
.expect("the modern catalog compose-prompt WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn e2e_public_websocket_bind_filesystem_provider_lists_and_reads_live_file() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket filesystem runtime installs an ambient context");
let root = std::env::temp_dir().join(format!(
"fastmcp-public-ws-fs-e2e-{}",
std::process::id()
));
std::fs::create_dir_all(&root)
.expect("the modern WebSocket filesystem root is created");
std::fs::write(root.join(PUBLIC_HTTP_FS_FILE_NAME), PUBLIC_HTTP_FS_FILE_TEXT)
.expect("the modern WebSocket filesystem file is written");
let handler = providers::FilesystemProvider::new(&root)
.with_prefix(PUBLIC_HTTP_FS_PREFIX)
.with_exclude(&[])
.build()
.expect("FilesystemProvider::build must succeed for the live modern WebSocket proof");
let server = modern::ServerBuilder::new("facade-ws-fs", "1.0.0")
.resource(handler)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect(
"public ModernOnly bind_websocket filesystem must bind a localhost listener",
);
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket filesystem publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket filesystem serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern filesystem handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket filesystem must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern filesystem initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-fs", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates filesystem templates over bind_websocket",
);
let listed = websocket_client_bounded(
&cx,
"live modern resources/templates/list",
client.list_resource_templates(&cx, None),
)
.await
.expect("live modern bind_websocket must list the FilesystemProvider template");
assert!(
listed.resource_templates.iter().any(|template| {
template.uri_template == PUBLIC_HTTP_FS_TEMPLATE
&& template.name == PUBLIC_HTTP_FS_PREFIX
}),
"FilesystemProvider must advertise its reversible file template: {:?}",
listed.resource_templates
);
let unmatched = websocket_client_bounded(
&cx,
"live modern unmatched filesystem resources/read",
client.read_resource(&cx, "file:///other/note.txt"),
)
.await
.expect_err(
"changing only the prefix the template cannot bind must refuse before dispatch",
);
assert_eq!(
unmatched.code,
McpErrorCode::InvalidParams,
"an unmatched modern filesystem URI must stay InvalidParams: {unmatched:?}"
);
let file = websocket_client_bounded(
&cx,
"live modern matched filesystem resources/read",
client.read_resource(&cx, PUBLIC_HTTP_FS_FILE_URI),
)
.await
.expect("resources/read must expand the live file URI through the filesystem handler");
assert!(
matches!(
file.contents.as_slice(),
[EmbeddedResourceContents::Text { text, .. }] if text == PUBLIC_HTTP_FS_FILE_TEXT
),
"the live modern WebSocket filesystem read must retain the file bytes: {:?}",
file.contents
);
websocket_client_bounded(&cx, "live modern filesystem close", client.close(&cx))
.await
.expect("the modern filesystem WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_mount_prefixes_tool_prompt_and_keeps_resource_uri() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket mount runtime installs an ambient context");
let child = modern::ServerBuilder::new("facade-ws-mounted-child", "1.0.0")
.tool(PublicHttpValue)
.prompt(PublicHttpInstructionPrompt)
.resource(PublicHttpSnapshotResource)
.build();
let server = modern::ServerBuilder::new("facade-ws-mounted-parent", "1.0.0")
.mount(child, Some("child"))
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket mount must bind a localhost listener");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket mount publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket mount serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern mount handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket mount must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern mount initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-mount", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates a mounted catalog over bind_websocket");
const MOUNTED_TOOL_NAME: &str = "child/public-http-e2e-tool";
const MOUNTED_PROMPT_NAME: &str = "child/public-http-e2e-prompt";
let listed = websocket_client_bounded(
&cx,
"live modern mounted tools/list",
client.list_tools(&cx, None),
)
.await
.expect("live modern WebSocket must list the prefixed child tool");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == MOUNTED_TOOL_NAME),
"modern mount() must rewrite the child tool name: {listed:?}"
);
assert!(
!listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME),
"a nonempty mount prefix must not keep the unprefixed child tool: {listed:?}"
);
let called = websocket_client_bounded(
&cx,
"live modern mounted tools/call",
client.call_tool(
&cx,
MOUNTED_TOOL_NAME,
json!({ "value": PUBLIC_HTTP_TOOL_ARGUMENT }),
),
)
.await
.expect("the prefixed child tool must dispatch");
assert!(
called.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == PUBLIC_HTTP_TOOL_TEXT,
_ => false,
}),
"the mounted live tool must retain the child handler value: {called:?}"
);
let listed_prompts = websocket_client_bounded(
&cx,
"live modern mounted prompts/list",
client.list_prompts(&cx, None),
)
.await
.expect("live modern WebSocket must list the prefixed child prompt");
assert!(
listed_prompts
.prompts
.iter()
.any(|prompt| prompt.name == MOUNTED_PROMPT_NAME),
"modern mount() must rewrite the child prompt name: {listed_prompts:?}"
);
let prompt = websocket_client_bounded(
&cx,
"live modern mounted prompts/get",
client.get_prompt(
&cx,
MOUNTED_PROMPT_NAME,
HashMap::from([(
"subject".to_owned(),
PUBLIC_HTTP_TOOL_ARGUMENT.to_owned(),
)]),
),
)
.await
.expect("the prefixed child prompt must dispatch");
let prompt = serde_json::to_value(prompt)
.expect("the mounted prompt result serializes for its observable assertion");
assert_eq!(
prompt["messages"][0]["content"]["text"],
json!(PUBLIC_HTTP_PROMPT_TEXT),
"the mounted live prompt must retain the child handler value: {prompt:?}"
);
let listed_resources = websocket_client_bounded(
&cx,
"live modern mounted resources/list",
client.list_resources(&cx, None),
)
.await
.expect("live modern WebSocket must list the unprefixed child resource");
assert!(
listed_resources
.resources
.iter()
.any(|resource| resource.uri.as_str() == PUBLIC_HTTP_RESOURCE_URI),
"an unprefixed modern mount must keep the exact final resource URI: {listed_resources:?}"
);
assert!(
listed_resources
.resources
.iter()
.all(|resource| !resource.uri.as_str().starts_with("child/")),
"a nonempty prefix must not invent a non-absolute modern resource URI: {listed_resources:?}"
);
let resource = websocket_client_bounded(
&cx,
"live modern mounted resources/read",
client.read_resource(&cx, PUBLIC_HTTP_RESOURCE_URI),
)
.await
.expect("the unprefixed child resource must dispatch");
assert!(
matches!(
resource.contents.as_slice(),
[EmbeddedResourceContents::Text { text, .. }] if text == PUBLIC_HTTP_RESOURCE_TEXT
),
"the mounted live resource must retain the child handler value: {:?}",
resource.contents
);
websocket_client_bounded(&cx, "live modern mount close", client.close(&cx))
.await
.expect("the modern mount WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[cfg(feature = "proxy")]
#[test]
fn e2e_public_websocket_bind_as_proxy_prefixes_tool_prompt_and_keeps_resource_uri() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket as_proxy runtime installs an ambient context");
let upstream = spawn_modern_facade_http_server(false, None, true);
let plan = ClientProtocolPlan::http(
ProtocolPolicy::ModernOnly,
Some(public_http_target(upstream.address(), "/mcp")),
None,
None,
"e2e-ws-proxy-gateway".to_owned(),
"e2e-ws-proxy-gateway".to_owned(),
"modern-http".to_owned(),
0,
0,
0,
)
.expect("modern as_proxy WebSocket gateway HTTP plan is valid");
let mut registry = ProxyClient::upstream_binding_registry();
let proxy = registry
.connect_http_with_protocol_plan(
"e2e-ws-live-upstream",
"native-h1:e2e-ws-live-upstream",
1,
plan,
ClientInfo {
name: "e2e-ws-proxy".to_owned(),
version: "1.0.0".to_owned(),
},
ClientCapabilities::default(),
cx.clone(),
)
.expect("live modern HTTP proxy upstream must connect from the WebSocket runtime");
let catalog = proxy
.catalog_typed()
.expect("live modern HTTP proxy catalog is typed");
let tool_names = catalog
.final_tools()
.map(|tools| {
tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>()
})
.unwrap_or_default();
assert!(
tool_names.contains(&PUBLIC_HTTP_TOOL_NAME),
"live modern HTTP proxy catalog omitted {PUBLIC_HTTP_TOOL_NAME}: {tool_names:?}"
);
let server = modern::ServerBuilder::new("e2e-ws-gateway", "1.0.0")
.as_proxy_typed("child", proxy, catalog)
.expect("modern as_proxy_typed install must succeed")
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket as_proxy must bind a localhost listener");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket as_proxy publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket as_proxy serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern as_proxy handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket as_proxy must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern as_proxy initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-as-proxy", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates as_proxy over bind_websocket");
const PREFIXED_TOOL_NAME: &str = "child/public-http-e2e-tool";
const PREFIXED_PROMPT_NAME: &str = "child/public-http-e2e-prompt";
let listed = websocket_client_bounded(
&cx,
"live modern as_proxy tools/list",
client.list_tools(&cx, None),
)
.await
.expect("live modern WebSocket as_proxy must list the prefixed upstream tool");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == PREFIXED_TOOL_NAME),
"modern as_proxy_typed must rewrite the upstream tool name: {listed:?}"
);
assert!(
!listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME),
"a nonempty as_proxy prefix must not keep the unprefixed upstream tool: {listed:?}"
);
let called = websocket_client_bounded(
&cx,
"live modern as_proxy tools/call",
client.call_tool(
&cx,
PREFIXED_TOOL_NAME,
json!({ "value": PUBLIC_HTTP_TOOL_ARGUMENT }),
),
)
.await
.expect("the prefixed upstream tool must dispatch through the live as_proxy gateway");
assert!(
called.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == PUBLIC_HTTP_TOOL_TEXT,
_ => false,
}),
"the proxied live tool must retain the upstream handler value: {called:?}"
);
let listed_prompts = websocket_client_bounded(
&cx,
"live modern as_proxy prompts/list",
client.list_prompts(&cx, None),
)
.await
.expect("live modern WebSocket as_proxy must list the prefixed upstream prompt");
assert!(
listed_prompts
.prompts
.iter()
.any(|prompt| prompt.name == PREFIXED_PROMPT_NAME),
"modern as_proxy_typed must rewrite the upstream prompt name: {listed_prompts:?}"
);
let prompt = websocket_client_bounded(
&cx,
"live modern as_proxy prompts/get",
client.get_prompt(
&cx,
PREFIXED_PROMPT_NAME,
HashMap::from([(
"subject".to_owned(),
PUBLIC_HTTP_TOOL_ARGUMENT.to_owned(),
)]),
),
)
.await
.expect("the prefixed upstream prompt must dispatch through the live as_proxy gateway");
let prompt = serde_json::to_value(prompt)
.expect("the proxied prompt result serializes for its observable assertion");
assert_eq!(
prompt["messages"][0]["content"]["text"],
json!(PUBLIC_HTTP_PROMPT_TEXT),
"the proxied live prompt must retain the upstream handler value: {prompt:?}"
);
let listed_resources = websocket_client_bounded(
&cx,
"live modern as_proxy resources/list",
client.list_resources(&cx, None),
)
.await
.expect("live modern WebSocket as_proxy must list the unprefixed upstream resource");
assert!(
listed_resources
.resources
.iter()
.any(|resource| resource.uri.as_str() == PUBLIC_HTTP_RESOURCE_URI),
"modern as_proxy_typed must keep the exact final resource URI: {listed_resources:?}"
);
assert!(
listed_resources
.resources
.iter()
.all(|resource| !resource.uri.as_str().starts_with("child/")),
"a nonempty as_proxy prefix must not invent a non-absolute modern resource URI: {listed_resources:?}"
);
let resource = websocket_client_bounded(
&cx,
"live modern as_proxy resources/read",
client.read_resource(&cx, PUBLIC_HTTP_RESOURCE_URI),
)
.await
.expect(
"the unprefixed upstream resource must dispatch through the live as_proxy gateway",
);
assert!(
matches!(
resource.contents.as_slice(),
[EmbeddedResourceContents::Text { text, .. }] if text == PUBLIC_HTTP_RESOURCE_TEXT
),
"the proxied live resource must retain the upstream handler value: {:?}",
resource.contents
);
websocket_client_bounded(&cx, "live modern as_proxy close", client.close(&cx))
.await
.expect("the modern as_proxy WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
upstream.shutdown();
});
}
#[test]
fn e2e_public_websocket_bind_transformed_tool_renames_argument_and_keeps_parent_unknown() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket transform runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-transform", "1.0.0")
.tool(
transform::TransformedTool::from_tool(PublicHttpValue)
.name(PUBLIC_HTTP_TRANSFORMED_TOOL_NAME)
.rename_arg("value", "query")
.build(),
)
.build();
let bound = server.bind_websocket(&cx, "127.0.0.1:0").await.expect(
"public ModernOnly bind_websocket transform must bind a localhost listener",
);
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket transform publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public ModernOnly bind_websocket transform serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern transform handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket transform must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern transform initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-transform", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates a transformed tool over bind_websocket",
);
let listed = websocket_client_bounded(
&cx,
"live modern transformed tools/list",
client.list_tools(&cx, None),
)
.await
.expect("live modern WebSocket must list the renamed tool");
let renamed = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_TRANSFORMED_TOOL_NAME)
.unwrap_or_else(|| {
panic!("the transformed catalog must advertise the new name: {listed:?}")
});
let schema = serde_json::to_string(&renamed.input_schema)
.expect("the renamed input schema serializes");
assert!(
schema.contains("\"query\""),
"the transformed schema must advertise the renamed argument: {schema}"
);
assert!(
!schema.contains("\"value\""),
"the transformed schema must drop the parent argument name: {schema}"
);
let called = websocket_client_bounded(
&cx,
"live modern renamed tools/call",
client.call_tool(
&cx,
PUBLIC_HTTP_TRANSFORMED_TOOL_NAME,
json!({"query": "alpha"}),
),
)
.await
.expect("the renamed argument must reach the parent handler");
assert!(
called.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"rename_arg must rewrite query back to the parent value: {called:?}"
);
let original = websocket_client_bounded(
&cx,
"live modern parent name after rename",
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect_err("the parent tool name must stay unknown after rename");
let original = format!("{original:?}");
assert!(
original.contains("not found")
|| original.contains("MethodNotFound")
|| original.contains("InvalidParams")
|| original.contains("ToolNotFound"),
"changing only the advertised name must refuse the parent tool: {original}"
);
websocket_client_bounded(&cx, "live modern transform close", client.close(&cx))
.await
.expect("the modern transform WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_transformed_tool_hides_argument_and_injects_default() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket hide-arg runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-hide", "1.0.0")
.tool(
transform::TransformedTool::from_tool(PublicHttpValue)
.name(PUBLIC_HTTP_HIDDEN_TOOL_NAME)
.hide_arg("value", "hidden-default")
.build(),
)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket hide-arg must bind a localhost listener");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket hide-arg publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public ModernOnly bind_websocket hide-arg serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern hide-arg handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket hide-arg must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern hide-arg initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-hide", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates hide-arg over bind_websocket");
let listed = websocket_client_bounded(
&cx,
"live modern hide-arg tools/list",
client.list_tools(&cx, None),
)
.await
.expect("live modern WebSocket must list the hide-arg tool");
let hidden = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_HIDDEN_TOOL_NAME)
.unwrap_or_else(|| {
panic!("the hide-arg catalog must advertise the new name: {listed:?}")
});
let schema = serde_json::to_string(&hidden.input_schema)
.expect("the hide-arg input schema serializes");
assert!(
!schema.contains("\"value\""),
"the hide-arg schema must drop the hidden argument: {schema}"
);
let injected = websocket_client_bounded(
&cx,
"live modern hide-arg tools/call",
client.call_tool(&cx, PUBLIC_HTTP_HIDDEN_TOOL_NAME, json!({})),
)
.await
.expect("the hide-arg tools/call must inject the configured default");
assert!(
injected.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:hidden-default",
_ => false,
}),
"the hidden default must reach the parent handler: {injected:?}"
);
let original = websocket_client_bounded(
&cx,
"live modern parent name after hide-arg",
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect_err("the parent tool name must stay unknown after hide-arg");
let original = format!("{original:?}");
assert!(
original.contains("not found")
|| original.contains("MethodNotFound")
|| original.contains("InvalidParams")
|| original.contains("ToolNotFound"),
"changing only the advertised name must refuse the parent tool: {original}"
);
websocket_client_bounded(&cx, "live modern hide-arg close", client.close(&cx))
.await
.expect("the modern hide-arg WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_rate_limit_refuses_second_same_method_and_admits_another() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket rate-limit runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-rate-limit", "1.0.0")
.middleware(rate_limiting::RateLimitingMiddleware::new(1.0e-300).burst_capacity(1))
.tool(PublicHttpTouchTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect(
"public ModernOnly bind_websocket rate-limit must bind a localhost listener",
);
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket rate-limit publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket rate-limit serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern rate-limit handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket rate-limit must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern rate-limit initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-rate-limit", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates rate limiting over bind_websocket");
let first = websocket_client_bounded(
&cx,
"live modern first rate-limited tools/call",
client.call_tool(&cx, PUBLIC_HTTP_TOUCH_TOOL_NAME, json!({})),
)
.await
.expect("the first modern WebSocket tools/call must be admitted by the live rate limiter");
assert!(
first.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "notified" || text == "silent",
_ => false,
}),
"the first live tools/call must reach the touch handler: {first:?}"
);
let limited = websocket_client_bounded(
&cx,
"live modern second rate-limited tools/call",
client.call_tool(&cx, PUBLIC_HTTP_TOUCH_TOOL_NAME, json!({})),
)
.await;
let limited = match limited {
Ok(result) => {
assert!(
result.is_error,
"a second tools/call must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
limited.contains("Rate limit exceeded"),
"the refused second modern WebSocket tools/call must keep the rate-limit error: {limited}"
);
let listed = websocket_client_bounded(
&cx,
"live modern tools/list after rate limit",
client.list_tools(&cx, None),
)
.await
.expect("changing only the method must still be admitted by the live rate limiter");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOUCH_TOOL_NAME),
"tools/list must still see the live tool after a rate-limited tools/call: {listed:?}"
);
websocket_client_bounded(&cx, "live modern rate-limit close", client.close(&cx))
.await
.expect("the modern rate-limit WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_response_cache_hits_same_allowlisted_call_and_misses_changed_args()
{
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket cache runtime installs an ambient context");
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let server = modern::ServerBuilder::new("facade-ws-cache", "1.0.0")
.middleware(
caching::ResponseCachingMiddleware::new()
.include_tools(vec![PUBLIC_HTTP_TOOL_NAME.to_owned()]),
)
.tool(CountingPublicHttpValue {
counters: Arc::clone(&handler_calls),
})
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket cache must bind a localhost listener");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket cache publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public ModernOnly bind_websocket cache serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern cache handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket cache must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern cache initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-cache", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates response caching over bind_websocket");
let first = websocket_client_bounded(
&cx,
"live modern first cached tools/call",
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect("the first allowlisted tools/call must miss and reach the handler");
assert!(
first.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the first live tools/call must return the handler result: {first:?}"
);
assert_eq!(
handler_calls.snapshot().tool,
1,
"the first allowlisted tools/call must invoke the counting handler"
);
let cached = websocket_client_bounded(
&cx,
"live modern cached tools/call",
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect("the second identical tools/call must hit the live cache");
assert!(
cached.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the cached live tools/call must retain the first handler result: {cached:?}"
);
assert_eq!(
handler_calls.snapshot().tool,
1,
"an identical allowlisted tools/call must not invoke the handler again"
);
let missed = websocket_client_bounded(
&cx,
"live modern cache miss tools/call",
client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "beta"})),
)
.await
.expect("changing only the arguments must miss the live cache");
assert!(
missed.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:beta",
_ => false,
}),
"the cache-miss tools/call must reach the handler: {missed:?}"
);
assert_eq!(
handler_calls.snapshot().tool,
2,
"changing only the arguments must increment the counting handler"
);
websocket_client_bounded(&cx, "live modern cache close", client.close(&cx))
.await
.expect("the modern cache WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_custom_token_refuses_wrong_and_commits_subject() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket custom-auth runtime installs an ambient context");
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let server = modern::ServerBuilder::new("facade-ws-custom-auth", "1.0.0")
.auth_provider(TokenAuthProvider::new(PublicHttpCustomVerifier))
.tool(PublicHttpAuthSubject {
counters: Arc::clone(&handler_calls),
})
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect(
"public ModernOnly bind_websocket custom auth must bind a localhost listener",
);
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket custom auth publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket custom auth serve must be admitted");
let (missing, _missing_stream) = websocket_upgrade_status(&cx, address, None).await;
assert!(
missing.starts_with(b"HTTP/1.1 401"),
"omitting Authorization must refuse the custom-token modern WebSocket upgrade: {}",
String::from_utf8_lossy(&missing)
);
assert!(
String::from_utf8_lossy(&missing)
.to_ascii_lowercase()
.contains("www-authenticate"),
"the missing-token custom modern WebSocket upgrade must challenge Bearer: {}",
String::from_utf8_lossy(&missing)
);
assert_eq!(
handler_calls.snapshot().tool,
0,
"missing Authorization must not invoke the custom-verified handler"
);
let (wrong, _wrong_stream) =
websocket_upgrade_status(&cx, address, Some("Bearer alpha")).await;
assert!(
wrong.starts_with(b"HTTP/1.1 401"),
"a static token must refuse the custom-token modern WebSocket upgrade: {}",
String::from_utf8_lossy(&wrong)
);
assert_eq!(
handler_calls.snapshot().tool,
0,
"changing only the custom verifier token must not invoke the handler"
);
let matching = format!("Bearer {PUBLIC_HTTP_CUSTOM_AUTH_TOKEN}");
let (accepted, stream) =
websocket_upgrade_status(&cx, address, Some(matching.as_str())).await;
assert!(
accepted.starts_with(b"HTTP/1.1 101"),
"the matching custom verifier token must complete RFC 6455 upgrade: {}",
String::from_utf8_lossy(&accepted)
);
let transport = AsyncWsClientTransport::from_upgraded(stream);
let mut client = websocket_client_bounded(
&cx,
"live modern custom-auth initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-custom-auth", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("an admitted custom-token WebSocket upgrade must negotiate ModernOnly");
let admitted = websocket_client_bounded(
&cx,
"live modern custom-auth tools/call",
client.call_tool(&cx, PUBLIC_HTTP_AUTH_TOOL_NAME, json!({})),
)
.await
.expect("the matching custom verifier token must reach the authenticated handler");
let expected_subject = format!("subject:{PUBLIC_HTTP_CUSTOM_AUTH_SUBJECT}");
assert!(
admitted.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == expected_subject.as_str(),
_ => false,
}),
"admitted modern WebSocket must commit the custom token subject into ctx.auth(): {admitted:?}"
);
assert_eq!(
handler_calls.snapshot().tool,
1,
"only the matching custom verifier token may invoke the authenticated handler"
);
websocket_client_bounded(&cx, "live modern custom-auth close", client.close(&cx))
.await
.expect("the modern WebSocket custom-auth client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_session_state_is_retained_and_peer_stays_isolated() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket state runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-state", "1.0.0")
.tool(PublicHttpStateTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket state must bind a localhost listener");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket state publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket state serve must be admitted");
let writer_transport = websocket_client_bounded(
&cx,
"live modern state writer handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("writer modern WebSocket must complete RFC 6455 upgrade");
let mut writer = websocket_client_bounded(
&cx,
"live modern state writer initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-state", "1.0.0")
.connect_websocket_with_cx(&cx, writer_transport),
)
.await
.expect("the writer modern WebSocket peer must initialize");
let written = websocket_client_bounded(
&cx,
"live modern session state write",
writer.call_tool(
&cx,
PUBLIC_HTTP_STATE_TOOL_NAME,
json!({"action": "write", "value": "alpha"}),
),
)
.await
.expect("live modern WebSocket must write session state");
assert!(
written.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "state:alpha",
_ => false,
}),
"set_state then get_state on the same WebSocket session must retain the value: {written:?}"
);
let later_read = websocket_client_bounded(
&cx,
"live modern session state later read",
writer.call_tool(&cx, PUBLIC_HTTP_STATE_TOOL_NAME, json!({"action": "read"})),
)
.await
.expect("a later modern WebSocket tools/call must still be admitted");
assert!(
later_read.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "state:alpha",
_ => false,
}),
"the same WebSocket session must reuse the previous bag: {later_read:?}"
);
let peer_transport = websocket_client_bounded(
&cx,
"live modern state peer handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("peer modern WebSocket must complete RFC 6455 upgrade");
let mut peer = websocket_client_bounded(
&cx,
"live modern state peer initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-state-peer", "1.0.0")
.connect_websocket_with_cx(&cx, peer_transport),
)
.await
.expect("the peer modern WebSocket session must initialize");
let peer_read = websocket_client_bounded(
&cx,
"live modern session state peer read",
peer.call_tool(&cx, PUBLIC_HTTP_STATE_TOOL_NAME, json!({"action": "read"})),
)
.await
.expect("a peer WebSocket session must still be admitted");
assert!(
peer_read.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "state:missing",
_ => false,
}),
"changing only the WebSocket session must not reuse the other session bag: {peer_read:?}"
);
drop(writer);
drop(peer);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_mask_error_details_hides_resource_execution_secret() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket mask runtime installs an ambient context");
let masked = modern::ServerBuilder::new("facade-ws-mask", "1.0.0")
.mask_error_details(true)
.resource(PublicHttpLeakResource)
.build();
let masked_bound = masked
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("masked modern WebSocket must bind");
let masked_address = masked_bound
.local_addr()
.expect("masked modern WebSocket publishes its address");
let unmasked = modern::ServerBuilder::new("facade-ws-unmask", "1.0.0")
.mask_error_details(false)
.resource(PublicHttpLeakResource)
.build();
let unmasked_bound = unmasked
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("unmasked modern WebSocket must bind");
let unmasked_address = unmasked_bound
.local_addr()
.expect("unmasked modern WebSocket publishes its address");
let scope = cx.scope();
let masked_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
masked_bound.serve(&serve_cx).await
})
.expect("masked modern WebSocket serve must be admitted");
let unmasked_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
unmasked_bound.serve(&serve_cx).await
})
.expect("unmasked modern WebSocket serve must be admitted");
let masked_transport = websocket_client_bounded(
&cx,
"live modern masked handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{masked_address}/mcp")),
)
.await
.expect("masked modern WebSocket must complete RFC 6455 upgrade");
let mut masked_client = websocket_client_bounded(
&cx,
"live modern masked initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-mask", "1.0.0")
.connect_websocket_with_cx(&cx, masked_transport),
)
.await
.expect("the masked modern WebSocket peer must initialize");
let masked = websocket_client_bounded(
&cx,
"live modern masked resources/read",
masked_client.read_resource(&cx, PUBLIC_HTTP_LEAK_RESOURCE_URI),
)
.await
.expect_err("a leaking resource must stay a resources/read error");
let masked = format!("{masked:?}");
assert!(
masked.contains("Internal server error"),
"modern WebSocket mask_error_details must replace the execution secret: {masked}"
);
assert!(
!masked.contains(PUBLIC_HTTP_LEAK_SECRET),
"modern WebSocket mask_error_details must not leak the execution secret: {masked}"
);
let unmasked_transport = websocket_client_bounded(
&cx,
"live modern unmasked handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{unmasked_address}/mcp")),
)
.await
.expect("unmasked modern WebSocket must complete RFC 6455 upgrade");
let mut unmasked_client = websocket_client_bounded(
&cx,
"live modern unmasked initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-unmask", "1.0.0")
.connect_websocket_with_cx(&cx, unmasked_transport),
)
.await
.expect("the unmasked modern WebSocket peer must initialize");
let unmasked = websocket_client_bounded(
&cx,
"live modern unmasked resources/read",
unmasked_client.read_resource(&cx, PUBLIC_HTTP_LEAK_RESOURCE_URI),
)
.await
.expect_err("changing only mask_error_details must still refuse the leaking resource");
let unmasked = format!("{unmasked:?}");
assert!(
unmasked.contains(PUBLIC_HTTP_LEAK_SECRET),
"disabling modern WebSocket mask_error_details must keep the execution secret: {unmasked}"
);
drop(masked_client);
drop(unmasked_client);
cx.set_cancel_requested(true);
masked_listener.abort();
unmasked_listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_on_duplicate_error_keeps_first_and_replace_installs_second() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket on_duplicate runtime installs an ambient context");
let keep = modern::ServerBuilder::new("facade-ws-dup-error", "1.0.0")
.on_duplicate(DuplicateBehavior::Error)
.tool(PublicHttpValue)
.tool(ReplacedPublicHttpValue)
.build();
let keep_bound = keep
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket duplicate-error must bind");
let keep_address = keep_bound
.local_addr()
.expect("public ModernOnly bind_websocket duplicate-error publishes its address");
let keep_scope = cx.scope();
let keep_listener = cx
.spawn_in(&keep_scope, move |serve_cx| async move {
keep_bound.serve(&serve_cx).await
})
.expect("public ModernOnly bind_websocket duplicate-error serve must be admitted");
let keep_transport = websocket_client_bounded(
&cx,
"live modern duplicate-error handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{keep_address}/mcp")),
)
.await
.expect("duplicate-error WebSocket must complete RFC 6455 upgrade");
let mut keep_client = websocket_client_bounded(
&cx,
"live modern duplicate-error initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-dup-error", "1.0.0")
.connect_websocket_with_cx(&cx, keep_transport),
)
.await
.expect("duplicate-error WebSocket must negotiate ModernOnly");
let kept = websocket_client_bounded(
&cx,
"live modern duplicate-error tools/call",
keep_client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect("Error must keep the first registration callable");
assert!(
kept.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"on_duplicate Error must keep the first handler: {kept:?}"
);
websocket_client_bounded(
&cx,
"live modern duplicate-error close",
keep_client.close(&cx),
)
.await
.expect("the duplicate-error WebSocket client closes");
drop(keep_client);
keep_listener.abort();
let replace = modern::ServerBuilder::new("facade-ws-dup-replace", "1.0.0")
.on_duplicate(DuplicateBehavior::Replace)
.tool(PublicHttpValue)
.tool(ReplacedPublicHttpValue)
.build();
let replace_bound = replace
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket duplicate-replace must bind");
let replace_address = replace_bound
.local_addr()
.expect("public ModernOnly bind_websocket duplicate-replace publishes its address");
let replace_scope = cx.scope();
let replace_listener = cx
.spawn_in(&replace_scope, move |serve_cx| async move {
replace_bound.serve(&serve_cx).await
})
.expect(
"public ModernOnly bind_websocket duplicate-replace serve must be admitted",
);
let replace_transport = websocket_client_bounded(
&cx,
"live modern duplicate-replace handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{replace_address}/mcp")),
)
.await
.expect("duplicate-replace WebSocket must complete RFC 6455 upgrade");
let mut replace_client = websocket_client_bounded(
&cx,
"live modern duplicate-replace initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-dup-replace", "1.0.0")
.connect_websocket_with_cx(&cx, replace_transport),
)
.await
.expect("duplicate-replace WebSocket must negotiate ModernOnly");
let replaced = websocket_client_bounded(
&cx,
"live modern duplicate-replace tools/call",
replace_client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect("Replace must install the second registration");
assert!(
replaced.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "replaced:alpha",
_ => false,
}),
"changing only on_duplicate to Replace must reach the second handler: {replaced:?}"
);
websocket_client_bounded(
&cx,
"live modern duplicate-replace close",
replace_client.close(&cx),
)
.await
.expect("the duplicate-replace WebSocket client closes");
drop(replace_client);
cx.set_cancel_requested(true);
replace_listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_tools_list_cursor_continues_and_rejects_kind_change() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket cursor runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-cursor", "1.0.0")
.list_page_size(1)
.tool(PublicHttpValue)
.tool(PublicHttpCursorSecondary)
.tool(PublicHttpCursorOther)
.resource(PublicHttpSnapshotResource)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket cursor must bind a localhost listener");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket cursor publishes its bound address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public ModernOnly bind_websocket cursor serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern cursor handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket cursor must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern cursor initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-cursor", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates catalog cursors over bind_websocket");
let first = websocket_client_bounded(
&cx,
"live modern first cursor tools/list",
client.list_tools(&cx, None),
)
.await
.expect("live modern WebSocket must page tools/list");
assert_eq!(
first.tools.len(),
1,
"page size 1 must create a real continuation: {first:?}"
);
let first_name = first.tools[0].name.clone();
let cursor = first
.next_cursor
.clone()
.expect("the first tools page must carry an opaque cursor");
let second = websocket_client_bounded(
&cx,
"live modern cursor continuation tools/list",
client.list_tools(&cx, Some(cursor.as_str())),
)
.await
.expect("the tools cursor continuation must reach the second live page");
assert_eq!(
second.tools.len(),
1,
"the tools continuation must retain one remaining name: {second:?}"
);
assert_ne!(
second.tools[0].name, first_name,
"the tools continuation must retain a different catalog name: {second:?}"
);
let kind_rejection = websocket_client_bounded(
&cx,
"live modern tools cursor kind mismatch",
client.list_resources(&cx, Some(cursor.as_str())),
)
.await
.expect_err("changing only the catalog kind must reject the tools cursor");
let kind_rejection = format!("{kind_rejection:?}");
assert!(
kind_rejection.contains("InvalidParams") || kind_rejection.contains("invalid"),
"a tools cursor must not select a resources page: {kind_rejection}"
);
websocket_client_bounded(&cx, "live modern catalog-cursor close", client.close(&cx))
.await
.expect("the modern catalog-cursor WebSocket client closes");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_initialize_retains_instructions_and_peer_stays_bare() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket instructions runtime installs an ambient context");
let instructed = modern::ServerBuilder::new("facade-ws-instructions", "1.0.0")
.instructions(PUBLIC_HTTP_INSTRUCTIONS)
.tool(PublicHttpValue)
.build();
let instructed_bound = instructed
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("instructed modern WebSocket must bind");
let instructed_address = instructed_bound
.local_addr()
.expect("instructed modern WebSocket publishes its address");
let bare = modern::ServerBuilder::new("facade-ws-instructions-bare", "1.0.0")
.tool(PublicHttpValue)
.build();
let bare_bound = bare
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("bare modern WebSocket must bind");
let bare_address = bare_bound
.local_addr()
.expect("bare modern WebSocket publishes its address");
let scope = cx.scope();
let instructed_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
instructed_bound.serve(&serve_cx).await
})
.expect("instructed modern WebSocket serve must be admitted");
let bare_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bare_bound.serve(&serve_cx).await
})
.expect("bare modern WebSocket serve must be admitted");
let instructed_transport = websocket_client_bounded(
&cx,
"live modern instructed handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{instructed_address}/mcp")),
)
.await
.expect("instructed modern WebSocket must complete RFC 6455 upgrade");
let instructed_client = websocket_client_bounded(
&cx,
"live modern instructed initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-instructions", "1.0.0")
.connect_websocket_with_cx(&cx, instructed_transport),
)
.await
.expect("the instructed modern WebSocket peer must initialize");
assert_eq!(
instructed_client.session().instructions(),
Some(PUBLIC_HTTP_INSTRUCTIONS),
"live modern bind_websocket discovery must retain the configured instructions"
);
let bare_transport = websocket_client_bounded(
&cx,
"live modern bare handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{bare_address}/mcp")),
)
.await
.expect("bare modern WebSocket must complete RFC 6455 upgrade");
let bare_client = websocket_client_bounded(
&cx,
"live modern bare initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-instructions-bare", "1.0.0")
.connect_websocket_with_cx(&cx, bare_transport),
)
.await
.expect("the bare modern WebSocket peer must initialize");
assert_eq!(
bare_client.session().instructions(),
None,
"changing only the missing instructions must keep the peer bare"
);
drop(instructed_client);
drop(bare_client);
cx.set_cancel_requested(true);
instructed_listener.abort();
bare_listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_discovery_retains_implementation_identity_and_peer_stays_bare() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket identity runtime installs an ambient context");
let identified = modern::ServerBuilder::new("facade-ws-identity", "1.0.0")
.title("Identity Title")
.description("Identity description")
.website_url("https://example.test/fastmcp")
.icons(vec![
RawIcon::try_new("https://example.test/e2e-icon.png")
.expect("the identity icon source is an absolute URI"),
])
.tool(PublicHttpValue)
.build();
let identified_bound = identified
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("identified modern WebSocket must bind");
let identified_address = identified_bound
.local_addr()
.expect("identified modern WebSocket publishes its address");
let bare = modern::ServerBuilder::new("facade-ws-identity-bare", "1.0.0")
.tool(PublicHttpValue)
.build();
let bare_bound = bare
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("bare modern WebSocket must bind");
let bare_address = bare_bound
.local_addr()
.expect("bare modern WebSocket publishes its address");
let scope = cx.scope();
let identified_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
identified_bound.serve(&serve_cx).await
})
.expect("identified modern WebSocket serve must be admitted");
let bare_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bare_bound.serve(&serve_cx).await
})
.expect("bare modern WebSocket serve must be admitted");
let identified_transport = websocket_client_bounded(
&cx,
"live modern identified handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{identified_address}/mcp")),
)
.await
.expect("identified modern WebSocket must complete RFC 6455 upgrade");
let identified_client = websocket_client_bounded(
&cx,
"live modern identified initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-identity", "1.0.0")
.connect_websocket_with_cx(&cx, identified_transport),
)
.await
.expect("the identified modern WebSocket peer must initialize");
let identified_discovery = identified_client
.session()
.server_discovery()
.expect("modern WebSocket discovery exposes handshake identity");
let identified_impl = identified_discovery
.implementation()
.expect("configured modern discovery must retain Implementation identity");
assert_eq!(identified_impl.name, "facade-ws-identity");
assert_eq!(identified_impl.version, "1.0.0");
assert_eq!(identified_impl.title.as_deref(), Some("Identity Title"));
assert_eq!(
identified_impl.description.as_deref(),
Some("Identity description")
);
assert_eq!(
identified_impl
.website_url
.as_ref()
.map(|uri| uri.as_str()),
Some("https://example.test/fastmcp")
);
assert_eq!(
identified_impl.icons.first().map(|icon| icon.src.as_str()),
Some("https://example.test/e2e-icon.png"),
"live bind_websocket discovery must retain the configured icon: {identified_impl:?}"
);
let bare_transport = websocket_client_bounded(
&cx,
"live modern bare-identity handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{bare_address}/mcp")),
)
.await
.expect("bare modern WebSocket must complete RFC 6455 upgrade");
let bare_client = websocket_client_bounded(
&cx,
"live modern bare-identity initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-identity-bare", "1.0.0")
.connect_websocket_with_cx(&cx, bare_transport),
)
.await
.expect("the bare modern WebSocket peer must initialize");
let bare_discovery = bare_client
.session()
.server_discovery()
.expect("modern WebSocket discovery exposes the missing-identity observable");
assert!(
bare_discovery.implementation().is_none(),
"changing only the missing identity extras must keep discovery name/version-only: {:?}",
bare_discovery.implementation()
);
assert_eq!(
bare_discovery
.server_info()
.map(|info| (info.name.as_str(), info.version.as_str())),
Some(("facade-ws-identity-bare", "1.0.0")),
"the bare peer must still advertise name and version"
);
drop(identified_client);
drop(bare_client);
cx.set_cancel_requested(true);
identified_listener.abort();
bare_listener.abort();
});
}
#[test]
fn e2e_public_websocket_client_implementation_is_visible_to_handler() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket client-identity runtime installs an ambient context",
);
let server = modern::ServerBuilder::new("facade-ws-client-identity", "1.0.0")
.tool(PublicHttpClientIdentity)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("modern WebSocket client-identity must bind");
let address = bound
.local_addr()
.expect("modern WebSocket client-identity publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("modern WebSocket client-identity serve must be admitted");
let identified_transport = websocket_client_bounded(
&cx,
"live modern identified client-identity handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("identified modern WebSocket must complete RFC 6455 upgrade");
let mut identified_client = websocket_client_bounded(
&cx,
"live modern identified client-identity initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-client-identity", "1.0.0")
.title("Client Title")
.connect_websocket_with_cx(&cx, identified_transport),
)
.await
.expect("the identified modern WebSocket peer must initialize");
let identified = websocket_client_bounded(
&cx,
"live modern identified client-identity tools/call",
identified_client.call_tool(&cx, PUBLIC_HTTP_CLIENT_IDENTITY_TOOL_NAME, json!({})),
)
.await
.expect("identified modern WebSocket tools/call must reach the client-identity handler");
assert!(
identified.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text == "name=e2e-public-ws-client-identity|title=Client Title"
}
_ => false,
}),
"live bind_websocket must attach ClientBuilder title onto McpContext: {identified:?}"
);
let bare_transport = websocket_client_bounded(
&cx,
"live modern bare client-identity handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("bare modern WebSocket must complete RFC 6455 upgrade");
let mut bare_client = websocket_client_bounded(
&cx,
"live modern bare client-identity initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-client-identity-bare", "1.0.0")
.connect_websocket_with_cx(&cx, bare_transport),
)
.await
.expect("the bare modern WebSocket peer must initialize");
let bare = websocket_client_bounded(
&cx,
"live modern bare client-identity tools/call",
bare_client.call_tool(&cx, PUBLIC_HTTP_CLIENT_IDENTITY_TOOL_NAME, json!({})),
)
.await
.expect("bare modern WebSocket tools/call must still reach the client-identity handler");
assert!(
bare.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text == "name=e2e-public-ws-client-identity-bare|title=none"
}
_ => false,
}),
"changing only the missing client title must keep handler-visible extras bare: {bare:?}"
);
drop(identified_client);
drop(bare_client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_client_info_is_visible_to_handler() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket client-identity runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-client-identity", "1.0.0")
.tool(PublicHttpClientIdentity)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("exact-2024 WebSocket client-identity must bind");
let address = bound
.local_addr()
.expect("exact-2024 WebSocket client-identity publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("exact-2024 WebSocket client-identity serve must be admitted");
let identified_transport = websocket_client_bounded(
&cx,
"live exact-2024 identified client-identity handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("identified exact-2024 WebSocket must complete RFC 6455 upgrade");
let mut identified_client = websocket_client_bounded(
&cx,
"live exact-2024 identified client-identity initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-client-identity", "1.0.0")
.connect_websocket_with_cx(&cx, identified_transport),
)
.await
.expect("the identified exact-2024 WebSocket peer must initialize");
let identified = websocket_client_bounded(
&cx,
"live exact-2024 identified client-identity tools/call",
identified_client.call_tool(&cx, PUBLIC_HTTP_CLIENT_IDENTITY_TOOL_NAME, json!({})),
)
.await
.expect("identified exact-2024 WebSocket tools/call must reach the client-identity handler");
assert_eq!(
legacy_http_tool_text(&identified).as_deref(),
Some("name=e2e-public-ws-legacy-client-identity|title=none"),
"live exact-2024 bind_websocket must attach initialize clientInfo onto McpContext: {identified:?}"
);
let other_transport = websocket_client_bounded(
&cx,
"live exact-2024 other client-identity handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("other exact-2024 WebSocket must complete RFC 6455 upgrade");
let mut other_client = websocket_client_bounded(
&cx,
"live exact-2024 other client-identity initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-client-identity-other", "1.0.0")
.connect_websocket_with_cx(&cx, other_transport),
)
.await
.expect("the other exact-2024 WebSocket peer must initialize");
let other = websocket_client_bounded(
&cx,
"live exact-2024 other client-identity tools/call",
other_client.call_tool(&cx, PUBLIC_HTTP_CLIENT_IDENTITY_TOOL_NAME, json!({})),
)
.await
.expect("the other exact-2024 WebSocket tools/call must still reach the client-identity handler");
assert_eq!(
legacy_http_tool_text(&other).as_deref(),
Some("name=e2e-public-ws-legacy-client-identity-other|title=none"),
"changing only the initialize client name must change the handler-visible identity: {other:?}"
);
drop(identified_client);
drop(other_client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_resources_and_prompts_cursors_continue_and_reject_kind_change() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket catalog-cursor runtime installs an ambient context",
);
let server = modern::ServerBuilder::new("facade-ws-catalog-cursor", "1.0.0")
.list_page_size(1)
.resource(PublicHttpSnapshotResource)
.resource(PublicHttpCursorResourceAResource)
.resource(PublicHttpCursorResourceBResource)
.prompt(PublicHttpCursorPromptAPrompt)
.prompt(PublicHttpCursorPromptBPrompt)
.build();
let bound = server.bind_websocket(&cx, "127.0.0.1:0").await.expect(
"public ModernOnly bind_websocket catalog cursor must bind a localhost listener",
);
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket catalog cursor publishes its bound address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public ModernOnly bind_websocket catalog cursor serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern catalog-cursor handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect(
"public ModernOnly bind_websocket catalog cursor must complete RFC 6455 upgrade",
);
let mut client = websocket_client_bounded(
&cx,
"live modern catalog-cursor initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-catalog-cursor", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates resource/prompt cursors");
let first_resources = websocket_client_bounded(
&cx,
"live modern first cursor resources/list",
client.list_resources(&cx, None),
)
.await
.expect("live modern WebSocket must page resources/list");
assert_eq!(
first_resources.resources.len(),
1,
"page size 1 must create a real resource continuation: {first_resources:?}"
);
let first_resource_uri = first_resources.resources[0].uri.as_str().to_owned();
let resource_cursor = first_resources
.next_cursor
.clone()
.expect("the first resources page must carry an opaque cursor");
let second_resources = websocket_client_bounded(
&cx,
"live modern cursor continuation resources/list",
client.list_resources(&cx, Some(resource_cursor.as_str())),
)
.await
.expect("the resources cursor continuation must reach the second live page");
assert_eq!(
second_resources.resources.len(),
1,
"the resource continuation must retain one remaining URI: {second_resources:?}"
);
assert_ne!(
second_resources.resources[0].uri.as_str(),
first_resource_uri,
"the resource continuation must retain a different URI: {second_resources:?}"
);
let resource_kind_rejection = websocket_client_bounded(
&cx,
"live modern resources cursor kind mismatch",
client.list_prompts(&cx, Some(resource_cursor.as_str())),
)
.await
.expect_err("changing only the catalog kind must reject the resources cursor");
let resource_kind_rejection = format!("{resource_kind_rejection:?}");
assert!(
resource_kind_rejection.contains("InvalidParams")
|| resource_kind_rejection.contains("invalid"),
"a resources cursor must not select a prompts page: {resource_kind_rejection}"
);
let first_prompts = websocket_client_bounded(
&cx,
"live modern first cursor prompts/list",
client.list_prompts(&cx, None),
)
.await
.expect("live modern WebSocket must page prompts/list");
assert_eq!(
first_prompts.prompts.len(),
1,
"page size 1 must create a real prompt continuation: {first_prompts:?}"
);
let first_prompt_name = first_prompts.prompts[0].name.clone();
let prompt_cursor = first_prompts
.next_cursor
.clone()
.expect("the first prompts page must carry an opaque cursor");
let second_prompts = websocket_client_bounded(
&cx,
"live modern cursor continuation prompts/list",
client.list_prompts(&cx, Some(prompt_cursor.as_str())),
)
.await
.expect("the prompts cursor continuation must reach the second live page");
assert_eq!(
second_prompts.prompts.len(),
1,
"the prompt continuation must retain one remaining name: {second_prompts:?}"
);
assert_ne!(
second_prompts.prompts[0].name, first_prompt_name,
"the prompt continuation must retain a different name: {second_prompts:?}"
);
let prompt_kind_rejection = websocket_client_bounded(
&cx,
"live modern prompts cursor kind mismatch",
client.list_tools(&cx, Some(prompt_cursor.as_str())),
)
.await
.expect_err("changing only the catalog kind must reject the prompts cursor");
let prompt_kind_rejection = format!("{prompt_kind_rejection:?}");
assert!(
prompt_kind_rejection.contains("InvalidParams")
|| prompt_kind_rejection.contains("invalid"),
"a prompts cursor must not select a tools page: {prompt_kind_rejection}"
);
websocket_client_bounded(&cx, "live modern catalog-cursor close", client.close(&cx))
.await
.expect("the modern resource/prompt cursor WebSocket client closes");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_sliding_window_refuses_second_same_method_and_admits_another() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket sliding-window runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-sliding-window", "1.0.0")
.middleware(rate_limiting::SlidingWindowRateLimitingMiddleware::new(1, 60))
.tool(PublicHttpTouchTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket sliding-window must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket sliding-window publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket sliding-window serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern sliding-window handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket sliding-window must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern sliding-window initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-sliding-window", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates sliding-window over bind_websocket");
websocket_client_bounded(
&cx,
"live modern first sliding-window tools/call",
client.call_tool(&cx, PUBLIC_HTTP_TOUCH_TOOL_NAME, json!({})),
)
.await
.expect("the first tools/call must be admitted by the live sliding window");
let limited = websocket_client_bounded(
&cx,
"live modern second sliding-window tools/call",
client.call_tool(&cx, PUBLIC_HTTP_TOUCH_TOOL_NAME, json!({})),
)
.await;
let limited = match limited {
Ok(result) => {
assert!(
result.is_error,
"a second tools/call must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
limited.contains("Rate limit exceeded"),
"the refused second modern WebSocket tools/call must keep the sliding-window error: {limited}"
);
let listed = websocket_client_bounded(
&cx,
"live modern tools/list after sliding window",
client.list_tools(&cx, None),
)
.await
.expect("changing only the method must still be admitted by the live sliding window");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOUCH_TOOL_NAME),
"tools/list must stay callable after tools/call is sliding-window limited: {listed:?}"
);
websocket_client_bounded(&cx, "live modern sliding-window close", client.close(&cx))
.await
.expect("the modern sliding-window WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_strict_input_validation_refuses_unknown_property_and_admits_declared_args()
{
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket strict-validation runtime installs an ambient context");
let strict = modern::ServerBuilder::new("facade-ws-strict-on", "1.0.0")
.strict_input_validation(true)
.tool(PublicHttpValue)
.build();
let strict_bound = strict
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("strict modern WebSocket must bind");
let strict_address = strict_bound
.local_addr()
.expect("strict modern WebSocket publishes its address");
let lenient = modern::ServerBuilder::new("facade-ws-strict-off", "1.0.0")
.strict_input_validation(false)
.tool(PublicHttpValue)
.build();
let lenient_bound = lenient
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("lenient modern WebSocket must bind");
let lenient_address = lenient_bound
.local_addr()
.expect("lenient modern WebSocket publishes its address");
let scope = cx.scope();
let strict_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
strict_bound.serve(&serve_cx).await
})
.expect("strict modern WebSocket serve must be admitted");
let lenient_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
lenient_bound.serve(&serve_cx).await
})
.expect("lenient modern WebSocket serve must be admitted");
let strict_transport = websocket_client_bounded(
&cx,
"live modern strict handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{strict_address}/mcp")),
)
.await
.expect("strict modern WebSocket must complete RFC 6455 upgrade");
let mut strict_client = websocket_client_bounded(
&cx,
"live modern strict initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-strict-on", "1.0.0")
.connect_websocket_with_cx(&cx, strict_transport),
)
.await
.expect("the strict modern WebSocket peer must initialize");
let admitted = websocket_client_bounded(
&cx,
"live modern strict declared tools/call",
strict_client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect("strict validation must still admit declared arguments");
assert!(
!admitted.is_error,
"declared arguments must not become a tool-level error: {admitted:?}"
);
assert!(
admitted.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"declared arguments must reach the handler under strict validation: {admitted:?}"
);
let refused = websocket_client_bounded(
&cx,
"live modern strict extra tools/call",
strict_client.call_tool(
&cx,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha", "extra": 1}),
),
)
.await
.expect("strict validation returns a complete tools/call result, not a transport failure");
assert!(
refused.is_error,
"an unknown property must become a tool-level error when strict validation is on: {refused:?}"
);
assert!(
refused.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => {
text.contains("do not match the declared input schema")
}
_ => false,
}),
"the strict refusal must name the input-schema mismatch: {refused:?}"
);
let lenient_transport = websocket_client_bounded(
&cx,
"live modern lenient handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{lenient_address}/mcp")),
)
.await
.expect("lenient modern WebSocket must complete RFC 6455 upgrade");
let mut lenient_client = websocket_client_bounded(
&cx,
"live modern lenient initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-strict-off", "1.0.0")
.connect_websocket_with_cx(&cx, lenient_transport),
)
.await
.expect("the lenient modern WebSocket peer must initialize");
let extra = websocket_client_bounded(
&cx,
"live modern lenient extra tools/call",
lenient_client.call_tool(
&cx,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha", "extra": 1}),
),
)
.await
.expect("lenient validation must admit the same extra property");
assert!(
!extra.is_error,
"changing only the strict flag must admit the extra property: {extra:?}"
);
assert!(
extra.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the extra property must not change the handler result when strict is off: {extra:?}"
);
drop(strict_client);
drop(lenient_client);
cx.set_cancel_requested(true);
strict_listener.abort();
lenient_listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_tool_default_parameter_is_injected_and_overridable() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket default-parameter runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-default", "1.0.0")
.tool(PublicHttpDefault)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket default-parameter must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket default-parameter publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket default-parameter serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern default handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket default must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern default initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-default", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates default injection over bind_websocket");
let listed = websocket_client_bounded(
&cx,
"live modern default tools/list",
client.list_tools(&cx, None),
)
.await
.expect("live modern WebSocket must list the default-parameter tool");
let default_tool = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_DEFAULT_TOOL_NAME)
.expect("the default-parameter tool must remain on the live catalog");
let required = default_tool
.input_schema
.get("required")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
assert!(
required.iter().any(|value| value.as_str() == Some("name")),
"the generated schema must still require name: {default_tool:?}"
);
assert!(
!required
.iter()
.any(|value| value.as_str() == Some("suffix")),
"the defaulted suffix must not stay required: {default_tool:?}"
);
assert_eq!(
default_tool
.input_schema
.pointer("/properties/suffix/default")
.and_then(serde_json::Value::as_str),
Some(PUBLIC_HTTP_DEFAULT_SUFFIX),
"tools/list must retain the generated default: {default_tool:?}"
);
let injected = websocket_client_bounded(
&cx,
"live modern default injected tools/call",
client.call_tool(&cx, PUBLIC_HTTP_DEFAULT_TOOL_NAME, json!({"name": "World"})),
)
.await
.expect("omitting the defaulted argument must still be admitted");
assert!(
injected.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "greet:World!",
_ => false,
}),
"the generated default must be injected at call time: {injected:?}"
);
let overridden = websocket_client_bounded(
&cx,
"live modern default overridden tools/call",
client.call_tool(
&cx,
PUBLIC_HTTP_DEFAULT_TOOL_NAME,
json!({"name": "World", "suffix": "?"}),
),
)
.await
.expect("supplying the defaulted argument must override the generated default");
assert!(
overridden.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "greet:World?",
_ => false,
}),
"changing only the suffix must override the generated default: {overridden:?}"
);
let missing_name = websocket_client_bounded(
&cx,
"live modern default missing-name tools/call",
client.call_tool(&cx, PUBLIC_HTTP_DEFAULT_TOOL_NAME, json!({"suffix": "!"})),
)
.await;
let missing_name = match missing_name {
Ok(result) => {
assert!(
result.is_error,
"a missing required generated argument must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
missing_name.contains("name")
|| missing_name.contains("required")
|| missing_name.contains("schema")
|| missing_name.contains("InvalidParams")
|| missing_name.contains("do not match"),
"a missing required generated argument must stay a handler-visible refusal: {missing_name}"
);
websocket_client_bounded(&cx, "live modern default close", client.close(&cx))
.await
.expect("the modern default-parameter WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_rich_content_retains_image_and_audio_blocks() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket rich-content runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-rich", "1.0.0")
.tool(PublicHttpRichTool)
.tool(PublicHttpPlainTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket rich-content must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket rich-content publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public ModernOnly bind_websocket rich-content serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern rich handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket rich must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern rich initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-rich", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates rich content over bind_websocket");
let rich = websocket_client_bounded(
&cx,
"live modern rich tools/call",
client.call_tool(&cx, PUBLIC_HTTP_RICH_TOOL_NAME, json!({})),
)
.await
.expect("live bind_websocket must retain image and audio content blocks");
assert!(
rich.content.iter().any(|content| match content {
ContentBlock::Image {
data, mime_type, ..
} => data == PUBLIC_HTTP_IMAGE_DATA && mime_type == PUBLIC_HTTP_IMAGE_MIME,
_ => false,
}),
"tools/call must retain the authored image block: {rich:?}"
);
assert!(
rich.content.iter().any(|content| match content {
ContentBlock::Audio {
data, mime_type, ..
} => data == PUBLIC_HTTP_AUDIO_DATA && mime_type == PUBLIC_HTTP_AUDIO_MIME,
_ => false,
}),
"tools/call must retain the authored audio block: {rich:?}"
);
let peer = websocket_client_bounded(
&cx,
"live modern rich peer tools/call",
client.call_tool(&cx, PUBLIC_HTTP_PLAIN_TOOL_NAME, json!({})),
)
.await
.expect("the text-only peer tool must still be callable");
assert!(
peer.content
.iter()
.all(|content| matches!(content, ContentBlock::Text { .. })),
"changing only the missing rich content must keep the peer text-only: {peer:?}"
);
assert!(
!peer.content.iter().any(|content| matches!(
content,
ContentBlock::Image { .. } | ContentBlock::Audio { .. }
)),
"the text-only peer must not invent image or audio blocks: {peer:?}"
);
websocket_client_bounded(&cx, "live modern rich close", client.close(&cx))
.await
.expect("the modern rich-content WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_output_schema_retains_structured_content_and_peer_stays_bare() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket output-schema runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-output-schema", "1.0.0")
.tool(PublicHttpOutputTool)
.tool(PublicHttpPlainTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket output-schema must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket output-schema publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket output-schema serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern output-schema handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket output-schema must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern output-schema initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-output-schema", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates output schema over bind_websocket");
let listed = websocket_client_bounded(
&cx,
"live modern output-schema tools/list",
client.list_tools(&cx, None),
)
.await
.expect("live bind_websocket must list the output-schema tools");
let output_tool = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_OUTPUT_TOOL_NAME)
.expect("the output-schema tool must remain on the live catalog");
let plain_tool = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_PLAIN_TOOL_NAME)
.expect("the bare peer tool must remain on the live catalog");
assert_eq!(
output_tool
.output_schema
.as_ref()
.and_then(|schema| schema.get("required")),
Some(&json!(["value"])),
"tools/list must retain the advertised output schema: {output_tool:?}"
);
assert_eq!(
plain_tool.output_schema, None,
"changing only the missing output schema must keep the peer bare: {plain_tool:?}"
);
let called = websocket_client_bounded(
&cx,
"live modern output-schema tools/call",
client.call_tool(&cx, PUBLIC_HTTP_OUTPUT_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect("live bind_websocket must admit the structured output tool");
assert!(
called.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the structured tool must still author text content: {called:?}"
);
assert_eq!(
called.structured_content,
Some(json!({"value": "alpha"})),
"tools/call must retain structuredContent matching the advertised schema: {called:?}"
);
let peer = websocket_client_bounded(
&cx,
"live modern output-schema peer tools/call",
client.call_tool(&cx, PUBLIC_HTTP_PLAIN_TOOL_NAME, json!({})),
)
.await
.expect("the bare peer tool must still be callable");
assert_eq!(
peer.structured_content, None,
"changing only the missing output schema must not invent structuredContent: {peer:?}"
);
websocket_client_bounded(&cx, "live modern output-schema close", client.close(&cx))
.await
.expect("the modern output-schema WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_two_clients_call_the_same_tool_independently() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket multi-client runtime installs an ambient context");
let handler_calls = Arc::new(PublicHttpHandlerCallCounters::default());
let server = modern::ServerBuilder::new("facade-ws-multi-client", "1.0.0")
.tool(CountingPublicHttpValue {
counters: Arc::clone(&handler_calls),
})
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket multi-client must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket multi-client publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public ModernOnly bind_websocket multi-client serve must be admitted");
let first_transport = websocket_client_bounded(
&cx,
"live modern multi-client first handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("the first modern WebSocket must complete RFC 6455 upgrade");
let mut first = websocket_client_bounded(
&cx,
"live modern multi-client first initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-multi-a", "1.0.0")
.connect_websocket_with_cx(&cx, first_transport),
)
.await
.expect("the first modern WebSocket peer must initialize");
let second_transport = websocket_client_bounded(
&cx,
"live modern multi-client second handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("the second modern WebSocket must complete RFC 6455 upgrade");
let mut second = websocket_client_bounded(
&cx,
"live modern multi-client second initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-multi-b", "1.0.0")
.connect_websocket_with_cx(&cx, second_transport),
)
.await
.expect("the second modern WebSocket peer must initialize");
let first_result = websocket_client_bounded(
&cx,
"live modern multi-client first tools/call",
first.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect("the first live client must reach the shared tool");
let second_result = websocket_client_bounded(
&cx,
"live modern multi-client second tools/call",
second.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "beta"})),
)
.await
.expect("the second live client must reach the shared tool independently");
assert!(
first_result.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:alpha",
_ => false,
}),
"the first client must keep its own tools/call result: {first_result:?}"
);
assert!(
second_result.content.iter().any(|content| match content {
ContentBlock::Text { text, .. } => text == "tool:beta",
_ => false,
}),
"changing only the second client's arguments must not reuse the first result: {second_result:?}"
);
assert_eq!(
handler_calls.snapshot().tool,
2,
"two independent WebSocket clients must each invoke the shared handler"
);
drop(first);
drop(second);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_list_tools_retains_final_icons_title_and_annotations() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket catalog-metadata runtime installs an ambient context",
);
let icons = vec![
RawIcon::try_new(PUBLIC_HTTP_ICON_SRC).expect("the catalog icon source is valid"),
];
let server = modern::ServerBuilder::new("facade-ws-catalog-metadata", "1.0.0")
.tool(PublicHttpIconTool {
icons: icons.clone(),
})
.tool(PublicHttpPlainTool)
.resource(PublicHttpIconResource {
icons: icons.clone(),
})
.resource(PublicHttpPlainResource)
.prompt(PublicHttpIconPrompt { icons })
.prompt(PublicHttpPlainPrompt)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket catalog metadata must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket catalog metadata publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public ModernOnly bind_websocket catalog metadata serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern catalog-metadata handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect(
"public ModernOnly bind_websocket catalog metadata must complete RFC 6455 upgrade",
);
let mut client = websocket_client_bounded(
&cx,
"live modern catalog-metadata initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-catalog-metadata", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates catalog metadata over bind_websocket");
let listed = websocket_client_bounded(
&cx,
"live modern catalog-metadata tools/list",
client.list_tools(&cx, None),
)
.await
.expect("live bind_websocket must list the catalog metadata tools");
let icon_tool = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_ICON_TOOL_NAME)
.expect("the icon tool must remain on the live catalog");
let plain_tool = listed
.tools
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_PLAIN_TOOL_NAME)
.expect("the plain peer tool must remain on the live catalog");
assert_eq!(icon_tool.title.as_deref(), Some("Icon Tool"));
let icons = icon_tool
.icons
.as_ref()
.expect("the icon tool must advertise its exact-final icons");
assert_eq!(icons.len(), 1);
assert_eq!(icons[0].src.as_str(), PUBLIC_HTTP_ICON_SRC);
let annotations = icon_tool
.annotations
.as_ref()
.expect("the icon tool must retain its projected annotations");
assert_eq!(annotations.read_only, Some(true));
assert_eq!(annotations.idempotent, Some(true));
assert_eq!(annotations.destructive, None);
assert_eq!(plain_tool.title, None);
assert_eq!(plain_tool.icons, None);
assert_eq!(plain_tool.annotations, None);
let listed_resources = websocket_client_bounded(
&cx,
"live modern catalog-metadata resources/list",
client.list_resources(&cx, None),
)
.await
.expect("live bind_websocket must list the catalog metadata resources");
let icon_resource = listed_resources
.resources
.iter()
.find(|resource| resource.uri.as_str() == PUBLIC_HTTP_ICON_RESOURCE_URI)
.expect("the icon resource must remain on the live catalog");
let plain_resource = listed_resources
.resources
.iter()
.find(|resource| resource.uri.as_str() == PUBLIC_HTTP_PLAIN_RESOURCE_URI)
.expect("the plain peer resource must remain on the live catalog");
assert_eq!(icon_resource.title.as_deref(), Some("Icon Resource"));
let resource_icons = icon_resource
.icons
.as_ref()
.expect("the icon resource must advertise its exact-final icons");
assert_eq!(resource_icons.len(), 1);
assert_eq!(resource_icons[0].src.as_str(), PUBLIC_HTTP_ICON_SRC);
assert_eq!(plain_resource.title, None);
assert_eq!(plain_resource.icons, None);
let listed_prompts = websocket_client_bounded(
&cx,
"live modern catalog-metadata prompts/list",
client.list_prompts(&cx, None),
)
.await
.expect("live bind_websocket must list the catalog metadata prompts");
let icon_prompt = listed_prompts
.prompts
.iter()
.find(|prompt| prompt.name == PUBLIC_HTTP_ICON_PROMPT_NAME)
.expect("the icon prompt must remain on the live catalog");
let plain_prompt = listed_prompts
.prompts
.iter()
.find(|prompt| prompt.name == PUBLIC_HTTP_PLAIN_PROMPT_NAME)
.expect("the plain peer prompt must remain on the live catalog");
assert_eq!(icon_prompt.title.as_deref(), Some("Icon Prompt"));
let prompt_icons = icon_prompt
.icons
.as_ref()
.expect("the icon prompt must advertise its exact-final icons");
assert_eq!(prompt_icons.len(), 1);
assert_eq!(prompt_icons[0].src.as_str(), PUBLIC_HTTP_ICON_SRC);
assert_eq!(plain_prompt.title, None);
assert_eq!(plain_prompt.icons, None);
websocket_client_bounded(&cx, "live modern catalog-metadata close", client.close(&cx))
.await
.expect("the modern catalog-metadata WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_list_tools_include_and_exclude_tags() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned modern WebSocket tag-filter runtime installs an ambient context");
let server = modern::ServerBuilder::new("facade-ws-tag-filter", "1.0.0")
.tool(PublicHttpValue)
.tool(PublicHttpCursorSecondary)
.tool(PublicHttpCursorOther)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket tag filter must bind");
let address = bound
.local_addr()
.expect("public ModernOnly bind_websocket tag filter publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public ModernOnly bind_websocket tag filter serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live modern tag-filter handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public ModernOnly bind_websocket tag filter must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live modern tag-filter initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-tag-filter", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the ModernOnly public facade negotiates tag filters over bind_websocket");
let cursor = websocket_client_bounded(
&cx,
"live modern tag-filter includeTags cursor",
client.list_tools_with_params(
&cx,
ListToolsParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListToolsParams::default()
},
),
)
.await
.expect("includeTags cursor must list the cursor-tagged WebSocket tools");
assert!(
cursor
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME),
"includeTags cursor must retain {PUBLIC_HTTP_TOOL_NAME}: {cursor:?}"
);
assert!(
cursor
.tools
.iter()
.all(|tool| tool.name != PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME),
"includeTags cursor must omit {PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME}: {cursor:?}"
);
let other = websocket_client_bounded(
&cx,
"live modern tag-filter includeTags other",
client.list_tools_with_params(
&cx,
ListToolsParams {
include_tags: Some(vec!["other".to_owned()]),
..ListToolsParams::default()
},
),
)
.await
.expect("changing only includeTags must list the other-tagged WebSocket tool");
assert!(
other
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME),
"includeTags other must retain {PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME}: {other:?}"
);
assert!(
other
.tools
.iter()
.all(|tool| tool.name != PUBLIC_HTTP_TOOL_NAME),
"includeTags other must omit {PUBLIC_HTTP_TOOL_NAME}: {other:?}"
);
let excluded = websocket_client_bounded(
&cx,
"live modern tag-filter excludeTags cursor",
client.list_tools_with_params(
&cx,
ListToolsParams {
exclude_tags: Some(vec!["cursor".to_owned()]),
..ListToolsParams::default()
},
),
)
.await
.expect("excludeTags cursor must omit only the cursor-tagged WebSocket tools");
assert!(
excluded
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME),
"excludeTags cursor must keep {PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME}: {excluded:?}"
);
assert!(
excluded
.tools
.iter()
.all(|tool| tool.name != PUBLIC_HTTP_TOOL_NAME),
"excludeTags cursor must omit {PUBLIC_HTTP_TOOL_NAME}: {excluded:?}"
);
let listed = websocket_client_bounded(
&cx,
"live modern tag-filter unfiltered tools/list",
client.list_tools(&cx, None),
)
.await
.expect("an unfiltered modern WebSocket tools/list must keep both tag groups");
assert!(
listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME)
&& listed
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME),
"omitting only the tag filters must list cursor and other tools: {listed:?}"
);
let live = McpRequestCancellation::new();
let live_filtered = websocket_client_bounded(
&cx,
"live modern tag-filter cancellation domain includeTags",
client.list_tools_with_params_and_cancellation(
&cx,
&live,
ListToolsParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListToolsParams::default()
},
),
)
.await
.expect("an uncancelled WebSocket domain must still send includeTags on tools/list");
assert!(
live_filtered
.tools
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOOL_NAME),
"live cancellation domain plus includeTags cursor must retain {PUBLIC_HTTP_TOOL_NAME}: {live_filtered:?}"
);
assert!(
live_filtered
.tools
.iter()
.all(|tool| tool.name != PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME),
"live cancellation domain plus includeTags cursor must omit {PUBLIC_HTTP_CURSOR_OTHER_TOOL_NAME}: {live_filtered:?}"
);
let already = McpRequestCancellation::new();
already.cancel();
let pre_send = websocket_client_bounded(
&cx,
"live modern tag-filter pre-send cancellation",
client.list_tools_with_params_and_cancellation(
&cx,
&already,
ListToolsParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListToolsParams::default()
},
),
)
.await
.expect_err("pre-send WebSocket cancellation must reject a tagged tools/list locally");
assert_eq!(
pre_send.code,
McpErrorCode::RequestCancelled,
"pre-send cancellation must stay RequestCancelled: {pre_send:?}"
);
websocket_client_bounded(&cx, "live modern tag-filter close", client.close(&cx))
.await
.expect("the modern tag-filter WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_bind_list_resources_prompts_and_templates_include_and_exclude_tags() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned modern WebSocket catalog tag-filter runtime installs an ambient context",
);
let server = modern::ServerBuilder::new("facade-ws-catalog-tag-filter", "1.0.0")
.resource(PublicHttpSnapshotResource)
.resource(PublicHttpCursorResourceAResource)
.prompt(PublicHttpInstructionPrompt)
.prompt(PublicHttpCursorPromptAPrompt)
.resource(PublicHttpCursorTemplateResource)
.resource(PublicHttpOtherTemplateResource)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public ModernOnly bind_websocket catalog tag filter must bind");
let address = bound.local_addr().expect(
"public ModernOnly bind_websocket catalog tag filter publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect(
"public ModernOnly bind_websocket catalog tag filter serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live modern catalog tag-filter handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect(
"public ModernOnly bind_websocket catalog tag filter must complete RFC 6455 upgrade",
);
let mut client = websocket_client_bounded(
&cx,
"live modern catalog tag-filter initialize",
modern::ClientBuilder::new()
.client_info("e2e-public-ws-catalog-tag-filter", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the ModernOnly public facade negotiates catalog tag filters over bind_websocket",
);
let cursor_resources = websocket_client_bounded(
&cx,
"live modern catalog tag-filter includeTags cursor resources",
client.list_resources_with_params(
&cx,
ListResourcesParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListResourcesParams::default()
},
),
)
.await
.expect("includeTags cursor must list the cursor-tagged WebSocket resource");
assert!(
cursor_resources
.resources
.iter()
.any(|resource| resource.uri.as_str() == PUBLIC_HTTP_CURSOR_RESOURCE_A_URI),
"includeTags cursor must retain {PUBLIC_HTTP_CURSOR_RESOURCE_A_URI}: {cursor_resources:?}"
);
assert!(
cursor_resources
.resources
.iter()
.all(|resource| resource.uri.as_str() != PUBLIC_HTTP_RESOURCE_URI),
"includeTags cursor must omit {PUBLIC_HTTP_RESOURCE_URI}: {cursor_resources:?}"
);
let excluded_resources = websocket_client_bounded(
&cx,
"live modern catalog tag-filter excludeTags cursor resources",
client.list_resources_with_params(
&cx,
ListResourcesParams {
exclude_tags: Some(vec!["cursor".to_owned()]),
..ListResourcesParams::default()
},
),
)
.await
.expect("excludeTags cursor must omit only the cursor-tagged WebSocket resource");
assert!(
excluded_resources
.resources
.iter()
.any(|resource| resource.uri.as_str() == PUBLIC_HTTP_RESOURCE_URI),
"excludeTags cursor must keep {PUBLIC_HTTP_RESOURCE_URI}: {excluded_resources:?}"
);
assert!(
excluded_resources
.resources
.iter()
.all(|resource| resource.uri.as_str() != PUBLIC_HTTP_CURSOR_RESOURCE_A_URI),
"excludeTags cursor must omit {PUBLIC_HTTP_CURSOR_RESOURCE_A_URI}: {excluded_resources:?}"
);
let cursor_prompts = websocket_client_bounded(
&cx,
"live modern catalog tag-filter includeTags cursor prompts",
client.list_prompts_with_params(
&cx,
ListPromptsParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListPromptsParams::default()
},
),
)
.await
.expect("includeTags cursor must list the cursor-tagged WebSocket prompt");
assert!(
cursor_prompts
.prompts
.iter()
.any(|prompt| prompt.name == PUBLIC_HTTP_CURSOR_PROMPT_A_NAME),
"includeTags cursor must retain {PUBLIC_HTTP_CURSOR_PROMPT_A_NAME}: {cursor_prompts:?}"
);
assert!(
cursor_prompts
.prompts
.iter()
.all(|prompt| prompt.name != PUBLIC_HTTP_PROMPT_NAME),
"includeTags cursor must omit {PUBLIC_HTTP_PROMPT_NAME}: {cursor_prompts:?}"
);
let excluded_prompts = websocket_client_bounded(
&cx,
"live modern catalog tag-filter excludeTags cursor prompts",
client.list_prompts_with_params(
&cx,
ListPromptsParams {
exclude_tags: Some(vec!["cursor".to_owned()]),
..ListPromptsParams::default()
},
),
)
.await
.expect("excludeTags cursor must omit only the cursor-tagged WebSocket prompt");
assert!(
excluded_prompts
.prompts
.iter()
.any(|prompt| prompt.name == PUBLIC_HTTP_PROMPT_NAME),
"excludeTags cursor must keep {PUBLIC_HTTP_PROMPT_NAME}: {excluded_prompts:?}"
);
assert!(
excluded_prompts
.prompts
.iter()
.all(|prompt| prompt.name != PUBLIC_HTTP_CURSOR_PROMPT_A_NAME),
"excludeTags cursor must omit {PUBLIC_HTTP_CURSOR_PROMPT_A_NAME}: {excluded_prompts:?}"
);
let cursor_templates = websocket_client_bounded(
&cx,
"live modern catalog tag-filter includeTags cursor templates",
client.list_resource_templates_with_params(
&cx,
ListResourceTemplatesParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListResourceTemplatesParams::default()
},
),
)
.await
.expect("includeTags cursor must list the cursor-tagged WebSocket template");
assert!(
cursor_templates
.resource_templates
.iter()
.any(|template| template.uri_template == PUBLIC_HTTP_CURSOR_TEMPLATE),
"includeTags cursor must retain {PUBLIC_HTTP_CURSOR_TEMPLATE}: {cursor_templates:?}"
);
assert!(
cursor_templates
.resource_templates
.iter()
.all(|template| template.uri_template != PUBLIC_HTTP_OTHER_TEMPLATE),
"includeTags cursor must omit {PUBLIC_HTTP_OTHER_TEMPLATE}: {cursor_templates:?}"
);
let other_templates = websocket_client_bounded(
&cx,
"live modern catalog tag-filter includeTags other templates",
client.list_resource_templates_with_params(
&cx,
ListResourceTemplatesParams {
include_tags: Some(vec!["other".to_owned()]),
..ListResourceTemplatesParams::default()
},
),
)
.await
.expect("changing only includeTags must list the other-tagged WebSocket template");
assert!(
other_templates
.resource_templates
.iter()
.any(|template| template.uri_template == PUBLIC_HTTP_OTHER_TEMPLATE),
"includeTags other must retain {PUBLIC_HTTP_OTHER_TEMPLATE}: {other_templates:?}"
);
assert!(
other_templates
.resource_templates
.iter()
.all(|template| template.uri_template != PUBLIC_HTTP_CURSOR_TEMPLATE),
"includeTags other must omit {PUBLIC_HTTP_CURSOR_TEMPLATE}: {other_templates:?}"
);
let live_resources = McpRequestCancellation::new();
let cancelled_resources = websocket_client_bounded(
&cx,
"live modern catalog tag-filter cancellation domain includeTags resources",
client.list_resources_with_params_and_cancellation(
&cx,
&live_resources,
ListResourcesParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListResourcesParams::default()
},
),
)
.await
.expect(
"an uncancelled WebSocket domain must still send includeTags on resources/list",
);
assert!(
cancelled_resources
.resources
.iter()
.any(|resource| resource.uri.as_str() == PUBLIC_HTTP_CURSOR_RESOURCE_A_URI),
"live cancellation domain plus includeTags cursor must retain {PUBLIC_HTTP_CURSOR_RESOURCE_A_URI}: {cancelled_resources:?}"
);
assert!(
cancelled_resources
.resources
.iter()
.all(|resource| resource.uri.as_str() != PUBLIC_HTTP_RESOURCE_URI),
"live cancellation domain plus includeTags cursor must omit {PUBLIC_HTTP_RESOURCE_URI}: {cancelled_resources:?}"
);
let live_prompts = McpRequestCancellation::new();
let cancelled_prompts = websocket_client_bounded(
&cx,
"live modern catalog tag-filter cancellation domain includeTags prompts",
client.list_prompts_with_params_and_cancellation(
&cx,
&live_prompts,
ListPromptsParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListPromptsParams::default()
},
),
)
.await
.expect("an uncancelled WebSocket domain must still send includeTags on prompts/list");
assert!(
cancelled_prompts
.prompts
.iter()
.any(|prompt| prompt.name == PUBLIC_HTTP_CURSOR_PROMPT_A_NAME),
"live cancellation domain plus includeTags cursor must retain {PUBLIC_HTTP_CURSOR_PROMPT_A_NAME}: {cancelled_prompts:?}"
);
assert!(
cancelled_prompts
.prompts
.iter()
.all(|prompt| prompt.name != PUBLIC_HTTP_PROMPT_NAME),
"live cancellation domain plus includeTags cursor must omit {PUBLIC_HTTP_PROMPT_NAME}: {cancelled_prompts:?}"
);
let live_templates = McpRequestCancellation::new();
let cancelled_templates = websocket_client_bounded(
&cx,
"live modern catalog tag-filter cancellation domain includeTags templates",
client.list_resource_templates_with_params_and_cancellation(
&cx,
&live_templates,
ListResourceTemplatesParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListResourceTemplatesParams::default()
},
),
)
.await
.expect(
"an uncancelled WebSocket domain must still send includeTags on resources/templates/list",
);
assert!(
cancelled_templates
.resource_templates
.iter()
.any(|template| template.uri_template == PUBLIC_HTTP_CURSOR_TEMPLATE),
"live cancellation domain plus includeTags cursor must retain {PUBLIC_HTTP_CURSOR_TEMPLATE}: {cancelled_templates:?}"
);
assert!(
cancelled_templates
.resource_templates
.iter()
.all(|template| template.uri_template != PUBLIC_HTTP_OTHER_TEMPLATE),
"live cancellation domain plus includeTags cursor must omit {PUBLIC_HTTP_OTHER_TEMPLATE}: {cancelled_templates:?}"
);
let already = McpRequestCancellation::new();
already.cancel();
let pre_send_resources = websocket_client_bounded(
&cx,
"live modern catalog tag-filter pre-send resources cancellation",
client.list_resources_with_params_and_cancellation(
&cx,
&already,
ListResourcesParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListResourcesParams::default()
},
),
)
.await
.expect_err(
"pre-send WebSocket cancellation must reject a tagged resources/list locally",
);
assert_eq!(
pre_send_resources.code,
McpErrorCode::RequestCancelled,
"pre-send resources cancellation must stay RequestCancelled: {pre_send_resources:?}"
);
let pre_send_prompts = websocket_client_bounded(
&cx,
"live modern catalog tag-filter pre-send prompts cancellation",
client.list_prompts_with_params_and_cancellation(
&cx,
&already,
ListPromptsParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListPromptsParams::default()
},
),
)
.await
.expect_err(
"pre-send WebSocket cancellation must reject a tagged prompts/list locally",
);
assert_eq!(
pre_send_prompts.code,
McpErrorCode::RequestCancelled,
"pre-send prompts cancellation must stay RequestCancelled: {pre_send_prompts:?}"
);
let pre_send_templates = websocket_client_bounded(
&cx,
"live modern catalog tag-filter pre-send templates cancellation",
client.list_resource_templates_with_params_and_cancellation(
&cx,
&already,
ListResourceTemplatesParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListResourceTemplatesParams::default()
},
),
)
.await
.expect_err(
"pre-send WebSocket cancellation must reject a tagged resources/templates/list locally",
);
assert_eq!(
pre_send_templates.code,
McpErrorCode::RequestCancelled,
"pre-send templates cancellation must stay RequestCancelled: {pre_send_templates:?}"
);
websocket_client_bounded(
&cx,
"live modern catalog tag-filter close",
client.close(&cx),
)
.await
.expect("the modern catalog tag-filter WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_list_resources_prompts_and_templates_include_and_exclude_tags()
{
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket catalog tag-filter runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-catalog-tag-filter", "1.0.0")
.resource(PublicHttpSnapshotResource)
.resource(PublicHttpCursorResourceAResource)
.prompt(PublicHttpInstructionPrompt)
.prompt(PublicHttpCursorPromptAPrompt)
.resource(PublicHttpCursorTemplateResource)
.resource(PublicHttpOtherTemplateResource)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket catalog tag filter must bind");
let address = bound.local_addr().expect(
"public LegacyOnly bind_websocket catalog tag filter publishes its address",
);
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect(
"public LegacyOnly bind_websocket catalog tag filter serve must be admitted",
);
let transport = websocket_client_bounded(
&cx,
"live exact-2024 catalog tag-filter handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect(
"public LegacyOnly bind_websocket catalog tag filter must complete RFC 6455 upgrade",
);
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 catalog tag-filter initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-catalog-tag-filter", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect(
"the LegacyOnly public facade negotiates catalog tag filters over bind_websocket",
);
let cursor_resources = websocket_client_bounded(
&cx,
"live exact-2024 catalog tag-filter includeTags cursor resources",
client.list_resources_page(
&cx,
ListResourcesParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListResourcesParams::default()
},
),
)
.await
.expect("includeTags cursor must list the cursor-tagged exact-2024 WebSocket resource");
assert!(
cursor_resources
.resources
.iter()
.any(|resource| resource.uri == PUBLIC_HTTP_CURSOR_RESOURCE_A_URI),
"includeTags cursor must retain {PUBLIC_HTTP_CURSOR_RESOURCE_A_URI}: {cursor_resources:?}"
);
assert!(
cursor_resources
.resources
.iter()
.all(|resource| resource.uri != PUBLIC_HTTP_RESOURCE_URI),
"includeTags cursor must omit {PUBLIC_HTTP_RESOURCE_URI}: {cursor_resources:?}"
);
assert!(
cursor_resources.resources.iter().any(|resource| {
resource.uri == PUBLIC_HTTP_CURSOR_RESOURCE_A_URI
&& resource.tags.iter().any(|tag| tag == "cursor")
}),
"exact-2024 includeTags must retain the cursor tag: {cursor_resources:?}"
);
let excluded_resources = websocket_client_bounded(
&cx,
"live exact-2024 catalog tag-filter excludeTags cursor resources",
client.list_resources_page(
&cx,
ListResourcesParams {
exclude_tags: Some(vec!["cursor".to_owned()]),
..ListResourcesParams::default()
},
),
)
.await
.expect("excludeTags cursor must omit only the cursor-tagged exact-2024 resource");
assert!(
excluded_resources
.resources
.iter()
.any(|resource| resource.uri == PUBLIC_HTTP_RESOURCE_URI),
"excludeTags cursor must keep {PUBLIC_HTTP_RESOURCE_URI}: {excluded_resources:?}"
);
assert!(
excluded_resources
.resources
.iter()
.all(|resource| resource.uri != PUBLIC_HTTP_CURSOR_RESOURCE_A_URI),
"excludeTags cursor must omit {PUBLIC_HTTP_CURSOR_RESOURCE_A_URI}: {excluded_resources:?}"
);
let cursor_prompts = websocket_client_bounded(
&cx,
"live exact-2024 catalog tag-filter includeTags cursor prompts",
client.list_prompts_page(
&cx,
ListPromptsParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListPromptsParams::default()
},
),
)
.await
.expect("includeTags cursor must list the cursor-tagged exact-2024 WebSocket prompt");
assert!(
cursor_prompts
.prompts
.iter()
.any(|prompt| prompt.name == PUBLIC_HTTP_CURSOR_PROMPT_A_NAME),
"includeTags cursor must retain {PUBLIC_HTTP_CURSOR_PROMPT_A_NAME}: {cursor_prompts:?}"
);
assert!(
cursor_prompts
.prompts
.iter()
.all(|prompt| prompt.name != PUBLIC_HTTP_PROMPT_NAME),
"includeTags cursor must omit {PUBLIC_HTTP_PROMPT_NAME}: {cursor_prompts:?}"
);
let excluded_prompts = websocket_client_bounded(
&cx,
"live exact-2024 catalog tag-filter excludeTags cursor prompts",
client.list_prompts_page(
&cx,
ListPromptsParams {
exclude_tags: Some(vec!["cursor".to_owned()]),
..ListPromptsParams::default()
},
),
)
.await
.expect("excludeTags cursor must omit only the cursor-tagged exact-2024 prompt");
assert!(
excluded_prompts
.prompts
.iter()
.any(|prompt| prompt.name == PUBLIC_HTTP_PROMPT_NAME),
"excludeTags cursor must keep {PUBLIC_HTTP_PROMPT_NAME}: {excluded_prompts:?}"
);
assert!(
excluded_prompts
.prompts
.iter()
.all(|prompt| prompt.name != PUBLIC_HTTP_CURSOR_PROMPT_A_NAME),
"excludeTags cursor must omit {PUBLIC_HTTP_CURSOR_PROMPT_A_NAME}: {excluded_prompts:?}"
);
let cursor_templates = websocket_client_bounded(
&cx,
"live exact-2024 catalog tag-filter includeTags cursor templates",
client.list_resource_templates_page(
&cx,
ListResourceTemplatesParams {
include_tags: Some(vec!["cursor".to_owned()]),
..ListResourceTemplatesParams::default()
},
),
)
.await
.expect("includeTags cursor must list the cursor-tagged exact-2024 template");
assert!(
cursor_templates
.resource_templates
.iter()
.any(|template| template.uri_template == PUBLIC_HTTP_CURSOR_TEMPLATE),
"includeTags cursor must retain {PUBLIC_HTTP_CURSOR_TEMPLATE}: {cursor_templates:?}"
);
assert!(
cursor_templates
.resource_templates
.iter()
.all(|template| template.uri_template != PUBLIC_HTTP_OTHER_TEMPLATE),
"includeTags cursor must omit {PUBLIC_HTTP_OTHER_TEMPLATE}: {cursor_templates:?}"
);
let other_templates = websocket_client_bounded(
&cx,
"live exact-2024 catalog tag-filter includeTags other templates",
client.list_resource_templates_page(
&cx,
ListResourceTemplatesParams {
include_tags: Some(vec!["other".to_owned()]),
..ListResourceTemplatesParams::default()
},
),
)
.await
.expect("changing only includeTags must list the other-tagged exact-2024 template");
assert!(
other_templates
.resource_templates
.iter()
.any(|template| template.uri_template == PUBLIC_HTTP_OTHER_TEMPLATE),
"includeTags other must retain {PUBLIC_HTTP_OTHER_TEMPLATE}: {other_templates:?}"
);
assert!(
other_templates
.resource_templates
.iter()
.all(|template| template.uri_template != PUBLIC_HTTP_CURSOR_TEMPLATE),
"includeTags other must omit {PUBLIC_HTTP_CURSOR_TEMPLATE}: {other_templates:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 catalog tag-filter close",
client.close(&cx),
)
.await
.expect(
"the exact-2024 catalog tag-filter WebSocket client closes after the live proof",
);
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_sliding_window_refuses_second_same_method_and_admits_another()
{
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket sliding-window runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-sliding", "1.0.0")
.middleware(rate_limiting::SlidingWindowRateLimitingMiddleware::new(1, 60))
.tool(PublicHttpTouchTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket sliding-window must bind");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket sliding-window publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket sliding-window serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 sliding-window handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket sliding-window must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 sliding-window initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-sliding", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates sliding-window over bind_websocket");
websocket_client_bounded(
&cx,
"live exact-2024 first sliding-window tools/call",
client.call_tool(&cx, PUBLIC_HTTP_TOUCH_TOOL_NAME, json!({})),
)
.await
.expect("the first exact-2024 WebSocket tools/call must be admitted by the sliding window");
let limited = websocket_client_bounded(
&cx,
"live exact-2024 second sliding-window tools/call",
client.call_tool(&cx, PUBLIC_HTTP_TOUCH_TOOL_NAME, json!({})),
)
.await;
let limited = match limited {
Ok(result) => {
assert!(
result.is_error,
"a second tools/call must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
limited.contains("Rate limit exceeded"),
"the refused second exact-2024 WebSocket tools/call must keep the sliding-window error: {limited}"
);
let listed = websocket_client_bounded(
&cx,
"live exact-2024 tools/list after sliding window",
client.list_tools(&cx),
)
.await
.expect("changing only the method must still be admitted by the live sliding window");
assert!(
listed
.iter()
.any(|tool| tool.name == PUBLIC_HTTP_TOUCH_TOOL_NAME),
"tools/list must stay callable after tools/call is sliding-window limited: {listed:?}"
);
websocket_client_bounded(&cx, "live exact-2024 sliding-window close", client.close(&cx))
.await
.expect("the exact-2024 sliding-window WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_strict_input_validation_refuses_unknown_property_and_admits_declared_args()
{
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket strict-validation runtime installs an ambient context",
);
let strict = legacy_2024::ServerBuilder::new("facade-ws-legacy-strict-on", "1.0.0")
.strict_input_validation(true)
.tool(PublicHttpValue)
.build();
let strict_bound = strict
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("strict exact-2024 WebSocket must bind");
let strict_address = strict_bound
.local_addr()
.expect("strict exact-2024 WebSocket publishes its address");
let lenient = legacy_2024::ServerBuilder::new("facade-ws-legacy-strict-off", "1.0.0")
.strict_input_validation(false)
.tool(PublicHttpValue)
.build();
let lenient_bound = lenient
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("lenient exact-2024 WebSocket must bind");
let lenient_address = lenient_bound
.local_addr()
.expect("lenient exact-2024 WebSocket publishes its address");
let scope = cx.scope();
let strict_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
strict_bound.serve(&serve_cx).await
})
.expect("strict exact-2024 WebSocket serve must be admitted");
let lenient_listener = cx
.spawn_in(&scope, move |serve_cx| async move {
lenient_bound.serve(&serve_cx).await
})
.expect("lenient exact-2024 WebSocket serve must be admitted");
let strict_transport = websocket_client_bounded(
&cx,
"live exact-2024 strict handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{strict_address}/mcp")),
)
.await
.expect("strict exact-2024 WebSocket must complete RFC 6455 upgrade");
let mut strict_client = websocket_client_bounded(
&cx,
"live exact-2024 strict initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-strict-on", "1.0.0")
.connect_websocket_with_cx(&cx, strict_transport),
)
.await
.expect("the strict exact-2024 WebSocket peer must initialize");
let admitted = websocket_client_bounded(
&cx,
"live exact-2024 strict declared tools/call",
strict_client.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect("strict validation must still admit declared arguments");
assert!(
!admitted.is_error,
"declared arguments must not become a tool-level error: {admitted:?}"
);
assert_eq!(
legacy_http_tool_text(&admitted).as_deref(),
Some("tool:alpha"),
"declared arguments must reach the handler under strict validation: {admitted:?}"
);
let refused = websocket_client_bounded(
&cx,
"live exact-2024 strict extra tools/call",
strict_client.call_tool(
&cx,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha", "extra": 1}),
),
)
.await;
let refused = match refused {
Ok(result) => {
assert!(
result.is_error,
"an unknown property must become a tool-level error when strict validation is on: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
refused.contains("do not match the declared input schema")
|| refused.contains("unknown")
|| refused.contains("InvalidParams")
|| refused.contains("extra")
|| refused.contains("additional property"),
"the strict refusal must name the input-schema mismatch: {refused}"
);
let lenient_transport = websocket_client_bounded(
&cx,
"live exact-2024 lenient handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{lenient_address}/mcp")),
)
.await
.expect("lenient exact-2024 WebSocket must complete RFC 6455 upgrade");
let mut lenient_client = websocket_client_bounded(
&cx,
"live exact-2024 lenient initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-strict-off", "1.0.0")
.connect_websocket_with_cx(&cx, lenient_transport),
)
.await
.expect("the lenient exact-2024 WebSocket peer must initialize");
let extra = websocket_client_bounded(
&cx,
"live exact-2024 lenient extra tools/call",
lenient_client.call_tool(
&cx,
PUBLIC_HTTP_TOOL_NAME,
json!({"value": "alpha", "extra": 1}),
),
)
.await
.expect("lenient validation must admit the same extra property");
assert!(
!extra.is_error,
"changing only the strict flag must admit the extra property: {extra:?}"
);
assert_eq!(
legacy_http_tool_text(&extra).as_deref(),
Some("tool:alpha"),
"the extra property must not change the handler result when strict is off: {extra:?}"
);
drop(strict_client);
drop(lenient_client);
cx.set_cancel_requested(true);
strict_listener.abort();
lenient_listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_tool_default_parameter_is_injected_and_overridable() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket default-parameter runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-default", "1.0.0")
.tool(PublicHttpDefault)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket default-parameter must bind");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket default-parameter publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket default-parameter serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 default handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket default must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 default initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-default", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates default injection over bind_websocket");
let listed = websocket_client_bounded(
&cx,
"live exact-2024 default tools/list",
client.list_tools(&cx),
)
.await
.expect("live exact-2024 WebSocket must list the default-parameter tool");
let default_tool = listed
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_DEFAULT_TOOL_NAME)
.expect("the default-parameter tool must remain on the live catalog");
let required = default_tool
.input_schema
.get("required")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
assert!(
required.iter().any(|value| value.as_str() == Some("name")),
"the generated schema must still require name: {default_tool:?}"
);
assert!(
!required
.iter()
.any(|value| value.as_str() == Some("suffix")),
"the defaulted suffix must not stay required: {default_tool:?}"
);
assert_eq!(
default_tool
.input_schema
.pointer("/properties/suffix/default")
.and_then(serde_json::Value::as_str),
Some(PUBLIC_HTTP_DEFAULT_SUFFIX),
"tools/list must retain the generated default: {default_tool:?}"
);
let injected = websocket_client_bounded(
&cx,
"live exact-2024 default injected tools/call",
client.call_tool(&cx, PUBLIC_HTTP_DEFAULT_TOOL_NAME, json!({"name": "World"})),
)
.await
.expect("omitting the defaulted argument must still be admitted");
assert_eq!(
legacy_http_tool_text(&injected).as_deref(),
Some("greet:World!"),
"the generated default must be injected at call time: {injected:?}"
);
let overridden = websocket_client_bounded(
&cx,
"live exact-2024 default overridden tools/call",
client.call_tool(
&cx,
PUBLIC_HTTP_DEFAULT_TOOL_NAME,
json!({"name": "World", "suffix": "?"}),
),
)
.await
.expect("supplying the defaulted argument must override the generated default");
assert_eq!(
legacy_http_tool_text(&overridden).as_deref(),
Some("greet:World?"),
"changing only the suffix must override the generated default: {overridden:?}"
);
let missing_name = websocket_client_bounded(
&cx,
"live exact-2024 default missing-name tools/call",
client.call_tool(&cx, PUBLIC_HTTP_DEFAULT_TOOL_NAME, json!({"suffix": "!"})),
)
.await;
let missing_name = match missing_name {
Ok(result) => {
assert!(
result.is_error,
"a missing required generated argument must stay an error result: {result:?}"
);
format!("{result:?}")
}
Err(error) => format!("{error:?}"),
};
assert!(
missing_name.contains("name")
|| missing_name.contains("required")
|| missing_name.contains("schema")
|| missing_name.contains("InvalidParams")
|| missing_name.contains("do not match"),
"a missing required generated argument must stay a handler-visible refusal: {missing_name}"
);
websocket_client_bounded(&cx, "live exact-2024 default close", client.close(&cx))
.await
.expect("the exact-2024 default-parameter WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_rich_content_retains_image_and_peer_stays_text() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket rich-content runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-rich", "1.0.0")
.tool(PublicHttpRichTool)
.tool(PublicHttpPlainTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket rich-content must bind");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket rich-content publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public LegacyOnly bind_websocket rich-content serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 rich handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("public LegacyOnly bind_websocket rich must complete RFC 6455 upgrade");
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 rich initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-rich", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates rich content over bind_websocket");
let rich = websocket_client_bounded(
&cx,
"live exact-2024 rich tools/call",
client.call_tool(&cx, PUBLIC_HTTP_RICH_TOOL_NAME, json!({})),
)
.await
.expect("live exact-2024 WebSocket must retain the representable image block");
let rich_value = legacy_http_content_value(&rich);
let content = rich_value
.get("content")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
assert!(
content.iter().any(|block| {
block.get("type").and_then(serde_json::Value::as_str) == Some("image")
&& block.get("data").and_then(serde_json::Value::as_str)
== Some(PUBLIC_HTTP_IMAGE_DATA)
&& block.get("mimeType").and_then(serde_json::Value::as_str)
== Some(PUBLIC_HTTP_IMAGE_MIME)
}),
"exact-2024 WebSocket tools/call must retain the authored image block: {rich_value}"
);
assert!(
content.iter().all(|block| {
block.get("type").and_then(serde_json::Value::as_str) != Some("audio")
}),
"exact-2024 WebSocket must not invent an audio block: {rich_value}"
);
let peer = websocket_client_bounded(
&cx,
"live exact-2024 rich peer tools/call",
client.call_tool(&cx, PUBLIC_HTTP_PLAIN_TOOL_NAME, json!({})),
)
.await
.expect("the text-only peer tool must still be callable");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("plain"),
"changing only the missing rich content must keep the peer text-only: {peer:?}"
);
websocket_client_bounded(&cx, "live exact-2024 rich close", client.close(&cx))
.await
.expect("the exact-2024 rich-content WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_output_schema_is_listed_and_call_stays_unstructured() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket output-schema runtime installs an ambient context",
);
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-output", "1.0.0")
.tool(PublicHttpOutputTool)
.tool(PublicHttpPlainTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket output-schema must bind");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket output-schema publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public LegacyOnly bind_websocket output-schema serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 output-schema handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect(
"public LegacyOnly bind_websocket output-schema must complete RFC 6455 upgrade",
);
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 output-schema initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-output", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates output schema over bind_websocket");
let listed = websocket_client_bounded(
&cx,
"live exact-2024 output-schema tools/list",
client.list_tools(&cx),
)
.await
.expect("live exact-2024 WebSocket must list the output-schema tools");
let output_tool = listed
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_OUTPUT_TOOL_NAME)
.expect("the output-schema tool must remain on the live catalog");
let plain_tool = listed
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_PLAIN_TOOL_NAME)
.expect("the bare peer tool must remain on the live catalog");
assert_eq!(
output_tool
.output_schema
.as_ref()
.and_then(|schema| schema.get("required")),
Some(&json!(["value"])),
"tools/list must retain the advertised output schema: {output_tool:?}"
);
assert_eq!(
plain_tool.output_schema, None,
"changing only the missing output schema must keep the peer bare: {plain_tool:?}"
);
let called = websocket_client_bounded(
&cx,
"live exact-2024 output-schema tools/call",
client.call_tool(&cx, PUBLIC_HTTP_OUTPUT_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect("live exact-2024 WebSocket must admit the structured output tool");
assert_eq!(
legacy_http_tool_text(&called).as_deref(),
Some("tool:alpha"),
"the structured tool must still author text content: {called:?}"
);
let called_value = legacy_http_content_value(&called);
assert!(
called_value.get("structuredContent").is_none()
|| called_value.get("structuredContent") == Some(&serde_json::Value::Null),
"exact-2024 WebSocket must not invent structuredContent: {called_value}"
);
let peer = websocket_client_bounded(
&cx,
"live exact-2024 output-schema peer tools/call",
client.call_tool(&cx, PUBLIC_HTTP_PLAIN_TOOL_NAME, json!({})),
)
.await
.expect("the bare peer tool must still be callable");
assert_eq!(
legacy_http_tool_text(&peer).as_deref(),
Some("plain"),
"the text-only peer tool must still complete: {peer:?}"
);
websocket_client_bounded(
&cx,
"live exact-2024 output-schema close",
client.close(&cx),
)
.await
.expect("the exact-2024 output-schema WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_two_clients_call_the_same_tool_independently() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current()
.expect("owned exact-2024 WebSocket multi-client runtime installs an ambient context");
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-multi", "1.0.0")
.tool(PublicHttpValue)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket multi-client must bind");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket multi-client publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move { bound.serve(&serve_cx).await })
.expect("public LegacyOnly bind_websocket multi-client serve must be admitted");
let first_transport = websocket_client_bounded(
&cx,
"live exact-2024 multi-client first handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("the first exact-2024 WebSocket must complete RFC 6455 upgrade");
let mut first = websocket_client_bounded(
&cx,
"live exact-2024 multi-client first initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-multi-a", "1.0.0")
.connect_websocket_with_cx(&cx, first_transport),
)
.await
.expect("the first exact-2024 WebSocket peer must initialize");
let second_transport = websocket_client_bounded(
&cx,
"live exact-2024 multi-client second handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect("the second exact-2024 WebSocket must complete RFC 6455 upgrade");
let mut second = websocket_client_bounded(
&cx,
"live exact-2024 multi-client second initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-multi-b", "1.0.0")
.connect_websocket_with_cx(&cx, second_transport),
)
.await
.expect("the second exact-2024 WebSocket peer must initialize");
let first_result = websocket_client_bounded(
&cx,
"live exact-2024 multi-client first tools/call",
first.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "alpha"})),
)
.await
.expect("the first live client must reach the shared tool");
let second_result = websocket_client_bounded(
&cx,
"live exact-2024 multi-client second tools/call",
second.call_tool(&cx, PUBLIC_HTTP_TOOL_NAME, json!({"value": "beta"})),
)
.await
.expect("the second live client must reach the shared tool independently");
assert_eq!(
legacy_http_tool_text(&first_result).as_deref(),
Some("tool:alpha"),
"the first client must keep its own tools/call result: {first_result:?}"
);
assert_eq!(
legacy_http_tool_text(&second_result).as_deref(),
Some("tool:beta"),
"changing only the second client's arguments must not reuse the first result: {second_result:?}"
);
drop(first);
drop(second);
cx.set_cancel_requested(true);
listener.abort();
});
}
#[test]
fn e2e_public_websocket_legacy_bind_list_tools_retains_version_tags_and_annotations() {
let runtime = websocket_test_runtime();
runtime.block_on(async {
let cx = Cx::current().expect(
"owned exact-2024 WebSocket catalog-metadata runtime installs an ambient context",
);
let icons = vec![
RawIcon::try_new(PUBLIC_HTTP_ICON_SRC).expect("the catalog icon source is valid"),
];
let server = legacy_2024::ServerBuilder::new("facade-ws-legacy-catalog", "1.0.0")
.tool(PublicHttpIconTool { icons })
.tool(PublicHttpPlainTool)
.build();
let bound = server
.bind_websocket(&cx, "127.0.0.1:0")
.await
.expect("public LegacyOnly bind_websocket catalog metadata must bind");
let address = bound
.local_addr()
.expect("public LegacyOnly bind_websocket catalog metadata publishes its address");
let scope = cx.scope();
let listener = cx
.spawn_in(&scope, move |serve_cx| async move {
bound.serve(&serve_cx).await
})
.expect("public LegacyOnly bind_websocket catalog metadata serve must be admitted");
let transport = websocket_client_bounded(
&cx,
"live exact-2024 catalog-metadata handshake",
AsyncWsClientTransport::connect(&cx, &format!("ws://{address}/mcp")),
)
.await
.expect(
"public LegacyOnly bind_websocket catalog metadata must complete RFC 6455 upgrade",
);
let mut client = websocket_client_bounded(
&cx,
"live exact-2024 catalog-metadata initialize",
legacy_2024::ClientBuilder::new()
.client_info("e2e-public-ws-legacy-catalog", "1.0.0")
.connect_websocket_with_cx(&cx, transport),
)
.await
.expect("the LegacyOnly public facade negotiates catalog metadata over bind_websocket");
let listed = websocket_client_bounded(
&cx,
"live exact-2024 catalog-metadata tools/list",
client.list_tools(&cx),
)
.await
.expect("live exact-2024 WebSocket must list the catalog metadata tools");
let icon_tool = listed
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_ICON_TOOL_NAME)
.expect("the annotated tool must remain on the live catalog");
let plain_tool = listed
.iter()
.find(|tool| tool.name == PUBLIC_HTTP_PLAIN_TOOL_NAME)
.expect("the plain peer tool must remain on the live catalog");
assert_eq!(icon_tool.version.as_deref(), Some("1.2.3"));
assert!(
icon_tool.tags.iter().any(|tag| tag == "catalog"),
"the annotated tool must retain its tags: {icon_tool:?}"
);
let annotations = icon_tool
.annotations
.as_ref()
.expect("the annotated tool must retain its projected annotations");
assert_eq!(annotations.read_only, Some(true));
assert_eq!(annotations.idempotent, Some(true));
assert_eq!(annotations.destructive, None);
assert_eq!(plain_tool.version, None);
assert!(plain_tool.tags.is_empty());
assert_eq!(plain_tool.annotations, None);
websocket_client_bounded(
&cx,
"live exact-2024 catalog-metadata close",
client.close(&cx),
)
.await
.expect("the exact-2024 catalog-metadata WebSocket client closes after the live proof");
drop(client);
cx.set_cancel_requested(true);
listener.abort();
});
}
}