use super::*;
use crate::mcp::streamable_http::testing::{ReplyMode, ServerBehavior, StreamableHttpFixture};
async fn connect(fixture: &StreamableHttpFixture) -> McpStreamableHttpClient {
let config = McpStreamableHttpServerConfig::new("fixture", fixture.endpoint());
McpStreamableHttpClient::connect(&config)
.await
.expect("the handshake should complete")
}
#[tokio::test]
async fn the_whole_conversation_goes_to_the_one_configured_endpoint() {
let fixture = StreamableHttpFixture::start(ReplyMode::Json);
let client = connect(&fixture).await;
client
.call_tool("echo", Some(serde_json::json!({"text": "hi"})))
.await
.expect("the tool call should succeed");
let requests = fixture.requests();
assert!(
!requests.is_empty(),
"the fixture should have seen requests"
);
for request in &requests {
assert_eq!(request.method, "POST", "every message is a POST");
assert_eq!(request.target, "/mcp", "every message goes to the endpoint");
}
assert_eq!(
fixture.rpc_methods(),
vec![
"initialize",
"notifications/initialized",
"tools/list",
"tools/call"
],
);
}
#[tokio::test]
async fn every_request_offers_both_json_and_an_event_stream() {
let fixture = StreamableHttpFixture::start(ReplyMode::Json);
let client = connect(&fixture).await;
client
.call_tool("echo", None)
.await
.expect("the tool call should succeed");
for request in fixture.requests() {
let accept = request
.header("accept")
.expect("every request should send Accept");
assert!(
accept.contains("application/json"),
"Accept must offer JSON, got {accept:?}"
);
assert!(
accept.contains("text/event-stream"),
"Accept must offer an event stream, got {accept:?}"
);
}
}
#[tokio::test]
async fn a_reply_arriving_as_one_json_body_is_read() {
let fixture = StreamableHttpFixture::start(ReplyMode::Json);
let client = connect(&fixture).await;
assert_eq!(client.tools().len(), 1);
assert_eq!(client.tools()[0].name, "echo");
assert_eq!(
client.server_info().map(|info| info.version.as_str()),
Some("9.9.9")
);
}
#[tokio::test]
async fn a_reply_arriving_as_an_event_stream_is_read() {
let fixture = StreamableHttpFixture::start(ReplyMode::EventStream);
let client = connect(&fixture).await;
assert_eq!(client.tools().len(), 1);
assert_eq!(client.tools()[0].name, "echo");
let result = client
.call_tool("echo", Some(serde_json::json!({"text": "hi"})))
.await
.expect("the tool call should succeed");
assert!(!result.is_error);
assert_eq!(result.content[0].text.as_deref(), Some("echoed"));
}
#[tokio::test]
async fn the_assigned_session_rides_on_every_later_request() {
let fixture =
StreamableHttpFixture::start_with(ReplyMode::Json, ServerBehavior::with_session("sess-42"));
let client = connect(&fixture).await;
assert_eq!(client.session_id().as_deref(), Some("sess-42"));
let requests = fixture.requests();
let (first, rest) = requests.split_first().expect("at least one request");
assert_eq!(first.rpc_method().as_deref(), Some("initialize"));
assert_eq!(
first.header("mcp-session-id"),
None,
"initialize cannot carry a session the server has not assigned yet"
);
assert!(!rest.is_empty(), "the handshake continues after initialize");
for request in rest {
assert_eq!(
request.header("mcp-session-id"),
Some("sess-42"),
"every request after initialize carries the session"
);
}
}
#[tokio::test]
async fn a_server_that_uses_no_session_is_never_sent_one() {
let fixture = StreamableHttpFixture::start(ReplyMode::Json);
let client = connect(&fixture).await;
assert_eq!(client.session_id(), None);
for request in fixture.requests() {
assert_eq!(request.header("mcp-session-id"), None);
}
}
#[tokio::test]
async fn the_negotiated_protocol_version_rides_on_later_requests() {
let fixture = StreamableHttpFixture::start(ReplyMode::Json);
connect(&fixture).await;
let requests = fixture.requests();
let (first, rest) = requests.split_first().expect("at least one request");
assert_eq!(
first.header("mcp-protocol-version"),
None,
"nothing is negotiated until initialize answers"
);
for request in rest {
assert_eq!(request.header("mcp-protocol-version"), Some("2025-06-18"));
}
}
#[tokio::test]
async fn a_protocol_version_this_client_cannot_speak_ends_the_handshake() {
let fixture = StreamableHttpFixture::start_with(
ReplyMode::Json,
ServerBehavior {
protocol_version: Some("2025-06-18\r\nX-Injected: yes".to_string()),
..ServerBehavior::default()
},
);
let config = McpStreamableHttpServerConfig::new("fixture", fixture.endpoint());
let error = McpStreamableHttpClient::connect(&config)
.await
.expect_err("an unsupported revision must not be negotiated");
assert!(
matches!(
error,
McpStreamableHttpError::UnsupportedProtocolVersion { .. }
),
"expected an unsupported revision, got {error:?}"
);
assert!(
!error.to_string().contains("X-Injected"),
"the server's value must not reach the error text: {error}"
);
for request in fixture.requests() {
assert_eq!(request.header("x-injected"), None);
}
}
#[tokio::test]
async fn the_older_streamable_revision_is_still_accepted() {
let fixture = StreamableHttpFixture::start_with(
ReplyMode::Json,
ServerBehavior {
protocol_version: Some("2025-03-26".to_string()),
..ServerBehavior::default()
},
);
let client = connect(&fixture).await;
assert_eq!(client.tools().len(), 1);
for request in fixture.requests().iter().skip(1) {
assert_eq!(
request.header("mcp-protocol-version"),
Some("2025-03-26"),
"the negotiated revision is what rides on later requests"
);
}
}
#[tokio::test]
async fn a_reply_answering_a_different_request_is_refused() {
let fixture = StreamableHttpFixture::start_with(
ReplyMode::Json,
ServerBehavior {
mismatch_reply_id_on: Some("tools/call".to_string()),
..ServerBehavior::default()
},
);
let client = connect(&fixture).await;
let error = client
.call_tool("echo", None)
.await
.expect_err("a reply for another request must not be accepted");
assert!(
matches!(error, McpStreamableHttpError::RequestIndeterminate { .. }),
"expected an indeterminate call, got {error:?}"
);
}
#[tokio::test]
async fn a_mismatched_reply_to_a_handshake_request_keeps_its_diagnostic() {
let fixture = StreamableHttpFixture::start_with(
ReplyMode::Json,
ServerBehavior {
mismatch_reply_id_on: Some("tools/list".to_string()),
..ServerBehavior::default()
},
);
let config = McpStreamableHttpServerConfig::new("fixture", fixture.endpoint());
let error = McpStreamableHttpClient::connect(&config)
.await
.expect_err("a reply for another request must not be accepted");
assert!(
matches!(
error,
McpStreamableHttpError::MismatchedReplyId {
method: "tools/list"
}
),
"expected a mismatched reply id, got {error:?}"
);
}
#[tokio::test]
async fn a_streamed_reply_answering_a_different_request_is_not_accepted() {
let fixture = StreamableHttpFixture::start_with(
ReplyMode::EventStream,
ServerBehavior {
mismatch_reply_id_on: Some("tools/call".to_string()),
..ServerBehavior::default()
},
);
let client = connect(&fixture).await;
let error = client
.call_tool("echo", None)
.await
.expect_err("a reply for another request must not be accepted");
assert!(
matches!(error, McpStreamableHttpError::RequestIndeterminate { .. }),
"expected an indeterminate call, got {error:?}"
);
}
#[tokio::test]
async fn a_json_rpc_error_keeps_its_code_and_drops_the_server_text() {
let fixture = StreamableHttpFixture::start_with(
ReplyMode::Json,
ServerBehavior {
tool_call_fails: true,
..ServerBehavior::default()
},
);
let client = connect(&fixture).await;
let error = client
.call_tool("echo", None)
.await
.expect_err("the server reported a tool failure");
let McpStreamableHttpError::JsonRpc(reported) = &error else {
panic!("expected a JSON-RPC error, got {error:?}");
};
assert_eq!(reported.code, -32000, "the code is fixed metadata and kept");
assert!(
!error.to_string().contains("tool exploded"),
"server text must never reach an error a log or a model will see: {error}"
);
}
#[tokio::test]
async fn an_http_rejection_fails_the_handshake_with_its_status() {
let fixture = StreamableHttpFixture::start_with(
ReplyMode::Json,
ServerBehavior {
http_status: Some(406),
..ServerBehavior::default()
},
);
let config = McpStreamableHttpServerConfig::new("fixture", fixture.endpoint());
let error = McpStreamableHttpClient::connect(&config)
.await
.expect_err("a 406 must fail the handshake");
assert!(
matches!(
error,
McpStreamableHttpError::HttpStatus { status, .. }
if status == reqwest::StatusCode::NOT_ACCEPTABLE
),
"expected an HTTP 406, got {error:?}"
);
}
#[tokio::test]
async fn a_configured_header_reaches_the_server_and_never_reaches_a_log() {
let fixture = StreamableHttpFixture::start(ReplyMode::Json);
let config = McpStreamableHttpServerConfig::new("fixture", fixture.endpoint())
.with_bearer_token("super-secret-token");
let client = McpStreamableHttpClient::connect(&config)
.await
.expect("the handshake should complete");
let requests = fixture.requests();
assert!(!requests.is_empty());
for request in &requests {
assert_eq!(
request.header("authorization"),
Some("Bearer super-secret-token"),
"the credential must reach the server it was configured for"
);
}
let rendered = format!("{client:?}");
assert!(
!rendered.contains("super-secret-token"),
"Debug must not print a credential: {rendered}"
);
let rendered_config = format!("{config:?}");
assert!(
!rendered_config.contains("super-secret-token"),
"Debug must not print a credential: {rendered_config}"
);
}
#[tokio::test]
async fn shutdown_ends_a_session_the_server_established() {
let fixture =
StreamableHttpFixture::start_with(ReplyMode::Json, ServerBehavior::with_session("sess-7"));
let client = connect(&fixture).await;
client.shutdown().await;
let deletes: Vec<_> = fixture
.requests()
.into_iter()
.filter(|request| request.method == "DELETE")
.collect();
assert_eq!(deletes.len(), 1, "shutdown should terminate the session");
assert_eq!(deletes[0].target, "/mcp");
assert_eq!(deletes[0].header("mcp-session-id"), Some("sess-7"));
}
#[tokio::test]
async fn shutdown_sends_nothing_when_there_is_no_session_to_end() {
let fixture = StreamableHttpFixture::start(ReplyMode::Json);
let client = connect(&fixture).await;
let before = fixture.requests().len();
client.shutdown().await;
assert_eq!(
fixture.requests().len(),
before,
"there is no session to terminate, so nothing is sent"
);
}
#[tokio::test]
async fn a_tool_call_returns_the_server_result() {
let fixture = StreamableHttpFixture::start(ReplyMode::Json);
let client = connect(&fixture).await;
let result = client
.call_tool("echo", Some(serde_json::json!({"text": "hi"})))
.await
.expect("the tool call should succeed");
assert!(!result.is_error);
assert_eq!(result.content.len(), 1);
assert_eq!(result.content[0].text.as_deref(), Some("echoed"));
let call = fixture
.requests()
.into_iter()
.find(|request| request.rpc_method().as_deref() == Some("tools/call"))
.expect("the call should have reached the server");
let body: serde_json::Value =
serde_json::from_str(&call.body).expect("the call body should be JSON");
assert_eq!(body["params"]["name"], "echo");
assert_eq!(body["params"]["arguments"]["text"], "hi");
}
#[tokio::test]
async fn an_unreachable_endpoint_fails_rather_than_hanging() {
let config = McpStreamableHttpServerConfig::new("unreachable", "http://127.0.0.1:1/mcp");
let error = McpStreamableHttpClient::connect(&config)
.await
.expect_err("an unreachable endpoint must fail");
assert!(
matches!(error, McpStreamableHttpError::Transport(_)),
"expected a transport failure, got {error:?}"
);
}
fn brisk_limits() -> McpStreamableHttpLimits {
McpStreamableHttpLimits {
connect_timeout: Duration::from_millis(500),
initialize_timeout: Duration::from_millis(300),
list_tools_timeout: Duration::from_millis(300),
call_tool_timeout: Duration::from_millis(300),
stream_idle_timeout: Duration::from_millis(200),
..McpStreamableHttpLimits::default()
}
}
#[tokio::test]
async fn a_stalled_notification_body_cannot_hang_the_handshake() {
let fixture = StreamableHttpFixture::start_with(
ReplyMode::Json,
ServerBehavior {
stall_after_headers_on: Some("notifications/initialized".to_string()),
..ServerBehavior::default()
},
);
let config = McpStreamableHttpServerConfig::new("fixture", fixture.endpoint())
.with_limits(brisk_limits());
let outcome = tokio::time::timeout(
Duration::from_secs(10),
McpStreamableHttpClient::connect(&config),
)
.await
.expect("connect must observe its own deadline rather than hang");
let error = outcome.expect_err("a server that never sends the body must fail the handshake");
assert!(
matches!(error, McpStreamableHttpError::Timeout(_)),
"expected a timeout, got {error:?}"
);
}
#[tokio::test]
async fn a_stalled_json_reply_reports_the_call_as_indeterminate() {
let fixture = StreamableHttpFixture::start_with(
ReplyMode::Json,
ServerBehavior {
stall_after_headers_on: Some("tools/call".to_string()),
..ServerBehavior::default()
},
);
let config = McpStreamableHttpServerConfig::new("fixture", fixture.endpoint())
.with_limits(brisk_limits());
let client = McpStreamableHttpClient::connect(&config)
.await
.expect("the handshake should complete");
let error = tokio::time::timeout(Duration::from_secs(10), client.call_tool("echo", None))
.await
.expect("the call must observe its own deadline rather than hang")
.expect_err("a reply that never arrives is not a success");
assert!(
matches!(error, McpStreamableHttpError::RequestIndeterminate { .. }),
"a call whose reply was lost may have run; got {error:?}"
);
}
#[tokio::test]
async fn a_server_error_after_the_call_was_accepted_is_indeterminate() {
let fixture = StreamableHttpFixture::start_with(
ReplyMode::Json,
ServerBehavior {
tool_call_status: Some(500),
..ServerBehavior::default()
},
);
let client = connect(&fixture).await;
let error = client
.call_tool("echo", None)
.await
.expect_err("a 500 is not a successful call");
assert!(
matches!(error, McpStreamableHttpError::RequestIndeterminate { .. }),
"a 5xx can follow a tool that already ran; got {error:?}"
);
}
#[tokio::test]
async fn a_client_error_on_the_call_is_a_definite_rejection() {
let fixture = StreamableHttpFixture::start_with(
ReplyMode::Json,
ServerBehavior {
tool_call_status: Some(401),
..ServerBehavior::default()
},
);
let client = connect(&fixture).await;
let error = client
.call_tool("echo", None)
.await
.expect_err("a 401 is not a successful call");
assert!(
matches!(
error,
McpStreamableHttpError::HttpStatus { status, .. }
if status == reqwest::StatusCode::UNAUTHORIZED
),
"a 4xx is refused before dispatch; got {error:?}"
);
}
#[tokio::test]
async fn a_server_reported_tool_failure_stays_a_definite_answer() {
let fixture = StreamableHttpFixture::start_with(
ReplyMode::Json,
ServerBehavior {
tool_call_fails: true,
..ServerBehavior::default()
},
);
let client = connect(&fixture).await;
let error = client
.call_tool("echo", None)
.await
.expect_err("the server reported a tool failure");
assert!(
matches!(error, McpStreamableHttpError::JsonRpc(_)),
"a reported failure is a definite answer; got {error:?}"
);
}
#[tokio::test]
async fn a_tool_list_exactly_at_the_page_limit_is_accepted() {
let fixture = StreamableHttpFixture::start_with(
ReplyMode::Json,
ServerBehavior {
tool_pages: Some(3),
..ServerBehavior::default()
},
);
let config = McpStreamableHttpServerConfig::new("fixture", fixture.endpoint()).with_limits(
McpStreamableHttpLimits {
max_tool_pages: 3,
..McpStreamableHttpLimits::default()
},
);
let client = McpStreamableHttpClient::connect(&config)
.await
.expect("a list exactly at the page limit is within it");
assert_eq!(client.tools().len(), 3, "every page should be collected");
}
#[tokio::test]
async fn a_single_page_limit_still_admits_a_single_page_server() {
let fixture = StreamableHttpFixture::start(ReplyMode::Json);
let config = McpStreamableHttpServerConfig::new("fixture", fixture.endpoint()).with_limits(
McpStreamableHttpLimits {
max_tool_pages: 1,
..McpStreamableHttpLimits::default()
},
);
let client = McpStreamableHttpClient::connect(&config)
.await
.expect("one page is not more than one page");
assert_eq!(client.tools().len(), 1);
}
#[tokio::test]
async fn a_server_that_never_stops_paginating_is_refused() {
let fixture = StreamableHttpFixture::start_with(
ReplyMode::Json,
ServerBehavior {
paginates_forever: true,
..ServerBehavior::default()
},
);
let config = McpStreamableHttpServerConfig::new("fixture", fixture.endpoint()).with_limits(
McpStreamableHttpLimits {
max_tool_pages: 4,
..McpStreamableHttpLimits::default()
},
);
let error = McpStreamableHttpClient::connect(&config)
.await
.expect_err("an endless walk must be stopped");
assert!(
matches!(error, McpStreamableHttpError::TooManyToolPages { limit: 4 }),
"expected the page cap to stop it, got {error:?}"
);
}
#[tokio::test]
async fn a_server_advertising_more_tools_than_the_cap_is_refused() {
let fixture = StreamableHttpFixture::start_with(
ReplyMode::Json,
ServerBehavior {
paginates_forever: true,
..ServerBehavior::default()
},
);
let config = McpStreamableHttpServerConfig::new("fixture", fixture.endpoint()).with_limits(
McpStreamableHttpLimits {
max_tools: 2,
..McpStreamableHttpLimits::default()
},
);
let error = McpStreamableHttpClient::connect(&config)
.await
.expect_err("an unbounded tool list must be stopped");
assert!(
matches!(error, McpStreamableHttpError::TooManyTools { limit: 2 }),
"expected the total-tools cap to stop it, got {error:?}"
);
}
#[tokio::test]
async fn a_session_id_outside_visible_ascii_is_not_adopted() {
let fixture =
StreamableHttpFixture::start_with(ReplyMode::Json, ServerBehavior::with_session("sess 42"));
let client = connect(&fixture).await;
assert_eq!(
client.session_id(),
None,
"a session id outside visible ASCII must not be adopted"
);
for request in fixture.requests().iter().skip(1) {
assert_eq!(request.header("mcp-session-id"), None);
}
}
#[tokio::test]
async fn shutdown_forgets_the_session_it_ended() {
let fixture =
StreamableHttpFixture::start_with(ReplyMode::Json, ServerBehavior::with_session("sess-9"));
let client = connect(&fixture).await;
client.shutdown().await;
assert_eq!(client.session_id(), None, "the session is gone");
let after_first = fixture.requests().len();
client.shutdown().await;
assert_eq!(
fixture.requests().len(),
after_first,
"a second shutdown has nothing left to end"
);
}
#[tokio::test]
async fn a_forgotten_session_is_replaced_and_the_call_goes_through() {
let fixture = StreamableHttpFixture::start_with(
ReplyMode::Json,
ServerBehavior {
session_id: Some("sess".to_string()),
rotate_session: true,
expire_first_tool_call: true,
..ServerBehavior::default()
},
);
let client = connect(&fixture).await;
assert_eq!(client.session_id().as_deref(), Some("sess-1"));
let result = client
.call_tool("echo", None)
.await
.expect("the call should survive the server forgetting the session");
assert_eq!(result.content[0].text.as_deref(), Some("echoed"));
assert_eq!(
client.session_id().as_deref(),
Some("sess-2"),
"the client should be holding the replacement session"
);
let initializes: Vec<_> = fixture
.requests()
.into_iter()
.filter(|request| request.rpc_method().as_deref() == Some("initialize"))
.collect();
assert_eq!(
initializes.len(),
2,
"the session should have been replaced"
);
assert_eq!(
initializes[1].header("mcp-session-id"),
None,
"a replacement initialize must not carry the forgotten session"
);
let calls = fixture
.requests()
.into_iter()
.filter(|request| request.rpc_method().as_deref() == Some("tools/call"))
.count();
assert_eq!(calls, 2, "one rejected before dispatch, one that ran");
}
#[tokio::test]
async fn a_server_that_rejects_every_session_stops_rather_than_looping() {
let fixture = StreamableHttpFixture::start_with(
ReplyMode::Json,
ServerBehavior {
session_id: Some("sess".to_string()),
rotate_session: true,
expire_every_tool_call: true,
..ServerBehavior::default()
},
);
let client = connect(&fixture).await;
let error = tokio::time::timeout(Duration::from_secs(10), client.call_tool("echo", None))
.await
.expect("a repeated expiry must terminate rather than loop")
.expect_err("every call is rejected, so none can succeed");
assert!(
matches!(error, McpStreamableHttpError::SessionExpired),
"expected the second rejection to be reported, got {error:?}"
);
let initializes = fixture
.requests()
.into_iter()
.filter(|request| request.rpc_method().as_deref() == Some("initialize"))
.count();
assert_eq!(initializes, 2, "recovery must be attempted exactly once");
}
#[tokio::test]
async fn dropping_a_client_still_tries_to_end_its_session() {
let fixture =
StreamableHttpFixture::start_with(ReplyMode::Json, ServerBehavior::with_session("sess-13"));
{
let client = connect(&fixture).await;
assert_eq!(client.session_id().as_deref(), Some("sess-13"));
}
let deadline = std::time::Instant::now() + Duration::from_secs(5);
let delete = loop {
if let Some(request) = fixture
.requests()
.into_iter()
.find(|request| request.method == "DELETE")
{
break request;
}
assert!(
std::time::Instant::now() < deadline,
"a dropped client should still attempt to end its session"
);
tokio::time::sleep(Duration::from_millis(25)).await;
};
assert_eq!(delete.target, "/mcp");
assert_eq!(delete.header("mcp-session-id"), Some("sess-13"));
}
#[tokio::test]
async fn dropping_an_already_shut_down_client_sends_nothing_more() {
let fixture =
StreamableHttpFixture::start_with(ReplyMode::Json, ServerBehavior::with_session("sess-14"));
let after_shutdown = {
let client = connect(&fixture).await;
client.shutdown().await;
fixture.requests().len()
};
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(
fixture.requests().len(),
after_shutdown,
"a client shut down cleanly must not send a second DELETE when dropped"
);
}