use super::*;
use super::handlers::*;
use super::session::*;
use axum::body::Body;
use axum::http::Request;
use proptest::prelude::*;
use tower::ServiceExt;
#[cfg(feature = "oauth")]
fn oauth_test_token(audience: &str, scope: &str) -> String {
jsonwebtoken::encode(
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
&serde_json::json!({
"sub": "test-user",
"aud": audience,
"scope": scope,
}),
&jsonwebtoken::EncodingKey::from_secret(b"resource-server-test-secret"),
)
.unwrap()
}
#[cfg(feature = "oauth")]
#[tokio::test]
async fn oauth_resource_server_setup_is_path_aware_and_audience_bound() {
let resource = "http://localhost:3000/tenant/mcp";
let metadata = crate::oauth::ProtectedResourceMetadata::new(resource)
.authorization_server("https://auth.example.com")
.scope("mcp:read");
let validator = crate::oauth::JwtValidator::from_secret(b"resource-server-test-secret")
.disable_exp_validation();
let app = HttpTransport::new(create_test_router())
.disable_origin_validation()
.disable_host_validation()
.into_oauth_router_at(
"/tenant/mcp",
validator,
metadata,
crate::oauth::ScopePolicy::new().default_scope("mcp:read"),
)
.unwrap();
let metadata_request = Request::builder()
.uri("/.well-known/oauth-protected-resource/tenant/mcp")
.body(Body::empty())
.unwrap();
let metadata_response = app.clone().oneshot(metadata_request).await.unwrap();
assert_eq!(metadata_response.status(), StatusCode::OK);
let unauthenticated = Request::builder()
.method("POST")
.uri("/tenant/mcp")
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(unauthenticated).await.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(
response
.headers()
.get("WWW-Authenticate")
.unwrap()
.to_str()
.unwrap()
.contains("http://localhost:3000/.well-known/oauth-protected-resource/tenant/mcp")
);
let wrong_audience = oauth_test_token("http://localhost:3000/other", "mcp:read");
let request = Request::builder()
.method("POST")
.uri("/tenant/mcp")
.header("Authorization", format!("Bearer {wrong_audience}"))
.body(Body::empty())
.unwrap();
assert_eq!(
app.clone().oneshot(request).await.unwrap().status(),
StatusCode::UNAUTHORIZED
);
let token = oauth_test_token(resource, "mcp:read");
let request = Request::builder()
.method("POST")
.uri("/tenant/mcp")
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "oauth-test", "version": "1.0" }
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("result").is_some(), "unexpected response: {json}");
}
#[cfg(feature = "oauth")]
#[test]
fn oauth_resource_server_setup_validates_metadata() {
let result = HttpTransport::new(create_test_router()).into_oauth_router(
crate::oauth::JwtValidator::from_secret(b"secret"),
crate::oauth::ProtectedResourceMetadata::new("https://mcp.example.com"),
crate::oauth::ScopePolicy::new(),
);
assert!(matches!(
result.unwrap_err(),
crate::oauth::ProtectedResourceMetadataError::MissingAuthorizationServer
));
}
fn arb_json() -> impl Strategy<Value = serde_json::Value> {
let leaf = prop_oneof![
Just(serde_json::Value::Null),
any::<bool>().prop_map(serde_json::Value::Bool),
any::<i64>().prop_map(|number| serde_json::json!(number)),
prop::collection::vec(any::<char>(), 0..256)
.prop_map(|chars| serde_json::Value::String(chars.into_iter().collect())),
];
leaf.prop_recursive(6, 128, 10, |inner| {
prop_oneof![
prop::collection::vec(inner.clone(), 0..10).prop_map(serde_json::Value::Array),
prop::collection::hash_map("[a-zA-Z0-9_]{0,24}", inner, 0..10)
.prop_map(|map| serde_json::Value::Object(map.into_iter().collect())),
]
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
#[test]
fn extract_request_id_never_panics(value in arb_json()) {
let _ = extract_request_id(&value);
}
#[test]
fn extract_request_id_accepts_all_i64_values(id in any::<i64>()) {
prop_assert_eq!(
extract_request_id(&serde_json::json!({ "id": id })),
Some(RequestId::Number(id))
);
}
#[test]
fn extract_request_id_accepts_arbitrary_strings(
chars in prop::collection::vec(any::<char>(), 0..1024)
) {
let id: String = chars.into_iter().collect();
prop_assert_eq!(
extract_request_id(&serde_json::json!({ "id": id })),
Some(RequestId::String(id))
);
}
}
fn create_test_router() -> McpRouter {
McpRouter::new().server_info("test-server", "1.0.0")
}
#[test]
fn final_result_fields_are_method_and_version_aware() {
for method in [
"server/discover",
"tools/list",
"prompts/list",
"resources/list",
"resources/read",
"resources/templates/list",
] {
let mut response =
JsonRpcResponse::result(RequestId::Number(1), serde_json::json!({"value": true}));
apply_protocol_result_fields(&mut response, method, PROTOCOL_VERSION_2026_07_28);
let json = serde_json::to_value(response).unwrap();
assert_eq!(json["result"]["resultType"], "complete", "{method}");
assert_eq!(json["result"]["ttlMs"], 0, "{method}");
assert_eq!(json["result"]["cacheScope"], "private", "{method}");
}
let mut ordinary =
JsonRpcResponse::result(RequestId::Number(1), serde_json::json!({"content": []}));
apply_protocol_result_fields(&mut ordinary, "tools/call", PROTOCOL_VERSION_2026_07_28);
let json = serde_json::to_value(ordinary).unwrap();
assert_eq!(json["result"]["resultType"], "complete");
assert!(json["result"].get("ttlMs").is_none());
assert!(json["result"].get("cacheScope").is_none());
}
#[test]
fn final_result_fields_preserve_explicit_values_and_legacy_wire_shape() {
let explicit = serde_json::json!({
"contents": [],
"ttlMs": 42,
"cacheScope": "public"
});
let mut response = JsonRpcResponse::result(RequestId::Number(1), explicit.clone());
apply_protocol_result_fields(&mut response, "resources/read", PROTOCOL_VERSION_2026_07_28);
let json = serde_json::to_value(response).unwrap();
assert_eq!(json["result"]["ttlMs"], 42);
assert_eq!(json["result"]["cacheScope"], "public");
for discriminator in ["input_required", "task"] {
let mut response = JsonRpcResponse::result(
RequestId::Number(1),
serde_json::json!({"resultType": discriminator}),
);
apply_protocol_result_fields(&mut response, "tools/call", PROTOCOL_VERSION_2026_07_28);
let json = serde_json::to_value(response).unwrap();
assert_eq!(json["result"]["resultType"], discriminator);
}
let mut legacy = JsonRpcResponse::result(RequestId::Number(1), explicit);
let before = serde_json::to_value(&legacy).unwrap();
apply_protocol_result_fields(&mut legacy, "resources/read", "2025-11-25");
assert_eq!(serde_json::to_value(legacy).unwrap(), before);
}
#[tokio::test]
#[cfg(feature = "stateless")]
async fn modern_subscription_registry_reports_closes() {
use crate::transport::subscriptions::{
SubscriptionClose, SubscriptionCloseReason, SubscriptionObserver,
};
#[derive(Default)]
struct RecordingObserver {
closes: std::sync::Mutex<Vec<SubscriptionClose>>,
}
impl SubscriptionObserver for RecordingObserver {
fn on_close(&self, close: SubscriptionClose) {
self.closes.lock().unwrap().push(close);
}
}
let observer = Arc::new(RecordingObserver::default());
let registry = Arc::new(ModernSubscriptionRegistry::new(
None,
Some(observer.clone()),
));
let (_rx, guard) = registry.register(
RequestId::String("disconnects".into()),
SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
},
);
drop(guard);
let (_rx2, guard2) = registry.register(
RequestId::String("drains".into()),
SubscriptionFilter::default(),
);
assert_eq!(registry.close_all(), 1);
drop(guard2);
let closes = observer.closes.lock().unwrap();
assert_eq!(closes.len(), 2, "one record per stream: {closes:?}");
assert_eq!(
closes[0].subscription_id,
RequestId::String("disconnects".into())
);
assert_eq!(closes[0].reason, SubscriptionCloseReason::Disconnected);
assert_eq!(
closes[1].subscription_id,
RequestId::String("drains".into())
);
assert_eq!(closes[1].reason, SubscriptionCloseReason::Drained);
}
#[tokio::test]
#[cfg(feature = "stateless")]
async fn modern_subscription_registry_filters_and_tags_notifications() {
let registry = Arc::new(ModernSubscriptionRegistry::default());
let (mut rx, guard) = registry.register(
RequestId::String("listen-1".to_string()),
SubscriptionFilter {
tools_list_changed: Some(true),
..SubscriptionFilter::default()
},
);
assert!(registry.publish(&ServerNotification::PromptsListChanged));
assert!(matches!(
rx.try_recv(),
Err(mpsc::error::TryRecvError::Empty)
));
assert!(registry.publish(&ServerNotification::ToolsListChanged));
let message = rx.recv().await.expect("matching notification");
let json: serde_json::Value = serde_json::from_str(&message).unwrap();
assert_eq!(json["method"], "notifications/tools/list_changed");
assert_eq!(
json["params"]["_meta"]["io.modelcontextprotocol/subscriptionId"],
"listen-1"
);
drop(guard);
assert!(registry.subscriptions.lock().unwrap().is_empty());
}
#[tokio::test]
async fn runtime_protocol_allowlist_drives_discovery() {
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.protocol_versions(["2025-03-26"])
.unwrap();
let app = transport.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "server/discover"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
json["result"]["supportedVersions"],
serde_json::json!(["2025-03-26"])
);
}
#[tokio::test]
async fn test_oversized_body_rejected_with_413() {
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.max_body_size(1024);
let app = transport.into_router();
let padding = "x".repeat(2048);
let body = format!(
r#"{{"jsonrpc":"2.0","id":1,"method":"ping","params":{{"pad":"{}"}}}}"#,
padding
);
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(body))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn test_oversized_content_length_rejected_without_reading() {
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.max_body_size(1024);
let app = transport.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("Content-Length", "10485760")
.body(Body::from(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn test_body_within_limit_accepted() {
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.max_body_size(1024);
let app = transport.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_initialize_creates_session() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(response.headers().contains_key(MCP_SESSION_ID_HEADER));
assert_eq!(
response
.headers()
.get(MCP_PROTOCOL_VERSION_HEADER)
.and_then(|v| v.to_str().ok()),
Some("2025-11-25")
);
}
#[tokio::test]
async fn test_protocol_version_header_on_subsequent_requests() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let init_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
})
.to_string(),
))
.unwrap();
let init_response = app.clone().oneshot(init_request).await.unwrap();
let session_id = init_response
.headers()
.get(MCP_SESSION_ID_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string();
assert_eq!(
init_response
.headers()
.get(MCP_PROTOCOL_VERSION_HEADER)
.and_then(|v| v.to_str().ok()),
Some("2025-03-26")
);
let initialized_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header(MCP_SESSION_ID_HEADER, &session_id)
.header(MCP_PROTOCOL_VERSION_HEADER, "2025-03-26")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
})
.to_string(),
))
.unwrap();
app.clone().oneshot(initialized_request).await.unwrap();
let list_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header(MCP_SESSION_ID_HEADER, &session_id)
.header(MCP_PROTOCOL_VERSION_HEADER, "2025-03-26")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(list_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(MCP_PROTOCOL_VERSION_HEADER)
.and_then(|v| v.to_str().ok()),
Some("2025-03-26")
);
}
#[tokio::test]
async fn unsupported_protocol_version_returns_spec_shape_error() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let init_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "t", "version": "0" }
}
})
.to_string(),
))
.unwrap();
let init_response = app.clone().oneshot(init_request).await.unwrap();
let session_id = init_response
.headers()
.get(MCP_SESSION_ID_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string();
let bad = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_SESSION_ID_HEADER, &session_id)
.header(MCP_PROTOCOL_VERSION_HEADER, "1999-01-01")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 99,
"method": "tools/list"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(bad).await.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"]["code"].as_i64().unwrap(), -32022);
assert_eq!(json["error"]["data"]["requested"], "1999-01-01");
let supported = json["error"]["data"]["supported"]
.as_array()
.expect("supported must be an array");
assert!(supported.contains(&serde_json::json!("2025-11-25")));
assert_eq!(json["id"], 99);
assert!(
json["error"]["data"].get("supportedVersions").is_none(),
"error data must use 'supported', not 'supportedVersions': {:?}",
json["error"]["data"]
);
let expected: Vec<serde_json::Value> = crate::COMPILED_PROTOCOL_VERSIONS
.iter()
.map(|v| serde_json::json!(v))
.collect();
assert_eq!(
supported, &expected,
"data.supported must exactly match COMPILED_PROTOCOL_VERSIONS"
);
}
#[cfg(feature = "stateless")]
#[tokio::test]
async fn stateless_unsupported_protocol_version_returns_spec_shape_error() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_PROTOCOL_VERSION_HEADER, "2099-01-01")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 42,
"method": "tools/list"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
json["error"]["code"].as_i64().unwrap(),
-32022,
"must return UnsupportedProtocolVersion (-32022): {json}"
);
assert_eq!(
json["error"]["data"]["requested"], "2099-01-01",
"data.requested must echo the version: {json}"
);
assert!(
json["error"]["data"].get("supportedVersions").is_none(),
"error data must use 'supported', not 'supportedVersions': {json}"
);
let supported = json["error"]["data"]["supported"]
.as_array()
.expect("data.supported must be an array");
let expected: Vec<serde_json::Value> = crate::COMPILED_PROTOCOL_VERSIONS
.iter()
.map(|v| serde_json::json!(v))
.collect();
assert_eq!(
supported, &expected,
"data.supported must exactly match COMPILED_PROTOCOL_VERSIONS"
);
}
#[tokio::test]
async fn sep_2243_lenient_mode_accepts_missing_headers() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let init = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "t", "version": "0" }
}
})
.to_string(),
))
.unwrap();
let init_response = app.clone().oneshot(init).await.unwrap();
assert_eq!(init_response.status(), StatusCode::OK);
let session_id = init_response
.headers()
.get(MCP_SESSION_ID_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header(MCP_SESSION_ID_HEADER, &session_id)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn sep_2243_lenient_mode_validates_present_headers() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let init = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header(MCP_METHOD_HEADER, "initialize")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "t", "version": "0" }
}
})
.to_string(),
))
.unwrap();
let init_response = app.clone().oneshot(init).await.unwrap();
assert_eq!(init_response.status(), StatusCode::OK);
let session_id = init_response
.headers()
.get(MCP_SESSION_ID_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_SESSION_ID_HEADER, &session_id)
.header(MCP_METHOD_HEADER, "ping")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"]["code"].as_i64().unwrap(), -32020);
assert!(
json["error"]["message"]
.as_str()
.unwrap()
.contains("Mcp-Method")
);
assert_eq!(json["id"], 2);
}
#[tokio::test]
#[cfg(not(feature = "stateless"))]
async fn sep_2243_strict_mode_tools_call_with_matching_headers() {
use crate::{CallToolResult, ToolBuilder};
let router = McpRouter::new().server_info("t", "1.0.0").tool(
ToolBuilder::new("echo")
.description("echo")
.handler(
|args: serde_json::Value| async move { Ok(CallToolResult::text(args.to_string())) },
)
.build(),
);
let transport = HttpTransport::new(router).disable_origin_validation();
let app = transport.into_router();
let init = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header(MCP_METHOD_HEADER, "initialize")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2026-07-28",
"capabilities": {},
"clientInfo": { "name": "t", "version": "0" }
}
})
.to_string(),
))
.unwrap();
let init_response = app.clone().oneshot(init).await.unwrap();
assert_eq!(init_response.status(), StatusCode::OK);
let session_id = init_response
.headers()
.get(MCP_SESSION_ID_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string();
let negotiated_version = init_response
.headers()
.get(MCP_PROTOCOL_VERSION_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_SESSION_ID_HEADER, &session_id)
.header(MCP_PROTOCOL_VERSION_HEADER, &negotiated_version)
.header(MCP_METHOD_HEADER, "tools/call")
.header(MCP_NAME_HEADER, "echo")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "echo",
"arguments": {"message": "hi"}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn sep_2243_tools_call_mcp_name_mismatch_rejected() {
use crate::{CallToolResult, ToolBuilder};
let router = McpRouter::new().server_info("t", "1.0.0").tool(
ToolBuilder::new("echo")
.description("echo")
.handler(
|args: serde_json::Value| async move { Ok(CallToolResult::text(args.to_string())) },
)
.build(),
);
let transport = HttpTransport::new(router).disable_origin_validation();
let app = transport.into_router();
let init = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "t", "version": "0" }
}
})
.to_string(),
))
.unwrap();
let init_response = app.clone().oneshot(init).await.unwrap();
let session_id = init_response
.headers()
.get(MCP_SESSION_ID_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_SESSION_ID_HEADER, &session_id)
.header(MCP_METHOD_HEADER, "tools/call")
.header(MCP_NAME_HEADER, "not-echo")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 7,
"method": "tools/call",
"params": {
"name": "echo",
"arguments": {"message": "hi"}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"]["code"].as_i64().unwrap(), -32020);
let msg = json["error"]["message"].as_str().unwrap();
assert!(msg.contains("Mcp-Name"), "got: {msg}");
assert_eq!(json["id"], 7);
}
#[tokio::test]
async fn sep_2243_mcp_param_base64_decoded_and_matched() {
use crate::{CallToolResult, ToolBuilder};
let router = McpRouter::new().server_info("t", "1.0.0").tool(
ToolBuilder::new("echo")
.description("echo")
.handler(
|args: serde_json::Value| async move { Ok(CallToolResult::text(args.to_string())) },
)
.build(),
);
let transport = HttpTransport::new(router).disable_origin_validation();
let app = transport.into_router();
let init = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "t", "version": "0" }
}
})
.to_string(),
))
.unwrap();
let init_response = app.clone().oneshot(init).await.unwrap();
let session_id = init_response
.headers()
.get(MCP_SESSION_ID_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_SESSION_ID_HEADER, &session_id)
.header(MCP_METHOD_HEADER, "tools/call")
.header(MCP_NAME_HEADER, "echo")
.header("mcp-param-message", "=?base64?SGVsbG8=?=")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "echo",
"arguments": {"message": "Hello"}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[cfg(feature = "stateless")]
#[tokio::test]
async fn sep_2243_final_request_requires_schema_annotated_header() {
use crate::extract::RawArgs;
use crate::{CallToolResult, ToolBuilder};
let tool = ToolBuilder::new("route")
.input_schema(serde_json::json!({
"type": "object",
"properties": {
"tenant_id": {
"type": "string",
"x-mcp-header": "Tenant"
}
}
}))
.extractor_handler((), |RawArgs(args): RawArgs| async move {
Ok(CallToolResult::text(args["tenant_id"].to_string()))
})
.build();
let app = HttpTransport::new(
McpRouter::new()
.server_info("header-test", "1.0.0")
.tool(tool),
)
.disable_origin_validation()
.into_router();
let request = |custom_header: Option<&'static str>| {
let mut builder = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header(MCP_PROTOCOL_VERSION_HEADER, PROTOCOL_VERSION_2026_07_28)
.header(MCP_METHOD_HEADER, "tools/call")
.header(MCP_NAME_HEADER, "route");
if let Some(value) = custom_header {
builder = builder.header("Mcp-Param-Tenant", value);
}
builder
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 9,
"method": "tools/call",
"params": {
"name": "route",
"arguments": {"tenant_id": "acme"},
"_meta": {
"io.modelcontextprotocol/protocolVersion":
PROTOCOL_VERSION_2026_07_28,
"io.modelcontextprotocol/clientCapabilities": {}
}
}
})
.to_string(),
))
.unwrap()
};
let response = app.clone().oneshot(request(None)).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let error: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(error["id"], 9);
assert_eq!(error["error"]["code"], -32020);
let response = app.oneshot(request(Some("acme"))).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn sep_2243_notification_with_matching_method_header_accepted() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let init = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "t", "version": "0" }
}
})
.to_string(),
))
.unwrap();
let init_response = app.clone().oneshot(init).await.unwrap();
let session_id = init_response
.headers()
.get(MCP_SESSION_ID_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header(MCP_SESSION_ID_HEADER, &session_id)
.header(MCP_METHOD_HEADER, "notifications/initialized")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
}
#[tokio::test]
async fn test_request_without_session_fails() {
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.require_sessions();
let app = transport.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("error").is_some());
assert_eq!(json["error"]["code"], -32006); }
#[tokio::test]
async fn test_delete_session() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let init_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
})
.to_string(),
))
.unwrap();
let response = app.clone().oneshot(init_request).await.unwrap();
let session_id = response
.headers()
.get(MCP_SESSION_ID_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string();
let delete_request = Request::builder()
.method("DELETE")
.uri("/")
.header(MCP_SESSION_ID_HEADER, &session_id)
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(delete_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let list_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header(MCP_SESSION_ID_HEADER, &session_id)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(list_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("error").is_some());
assert_eq!(json["error"]["code"], -32005); }
#[tokio::test]
async fn test_custom_session_store_receives_create_and_delete() {
use crate::session_store::{MemorySessionStore, SessionStore as PublicSessionStore};
let store = Arc::new(MemorySessionStore::new());
let store_dyn: Arc<dyn PublicSessionStore> = store.clone();
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.session_store(store_dyn);
let (app, handle) = transport.into_router_with_handle();
let init_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "test-client", "version": "1.0.0" }
}
})
.to_string(),
))
.unwrap();
let response = app.clone().oneshot(init_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let session_id = response
.headers()
.get(MCP_SESSION_ID_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string();
assert_eq!(store.len().await, 1);
let record = store
.load(&session_id)
.await
.unwrap()
.expect("expected session to be persisted");
assert_eq!(record.id, session_id);
let client_info = record
.client_info
.expect("client_info should be populated after initialize");
assert_eq!(client_info.name, "test-client");
assert_eq!(client_info.version, "1.0.0");
assert!(
record.client_capabilities.is_some(),
"client_capabilities should be populated after initialize"
);
assert!(handle.terminate_session(&session_id).await);
assert_eq!(store.len().await, 0);
assert!(store.load(&session_id).await.unwrap().is_none());
}
#[tokio::test]
async fn test_session_store_record_carries_negotiated_protocol_version() {
use crate::session_store::{MemorySessionStore, SessionStore as PublicSessionStore};
let store = Arc::new(MemorySessionStore::new());
let store_dyn: Arc<dyn PublicSessionStore> = store.clone();
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.session_store(store_dyn);
let app = transport.into_router();
let init_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": { "name": "v-client", "version": "2.0.0" }
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(init_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let session_id = response
.headers()
.get(MCP_SESSION_ID_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string();
let record = store
.load(&session_id)
.await
.unwrap()
.expect("session should be persisted");
assert_eq!(record.protocol_version, "2025-03-26");
let client_info = record.client_info.expect("client_info should be populated");
assert_eq!(client_info.name, "v-client");
}
#[tokio::test]
async fn test_restored_session_exposes_original_client_info() {
use crate::session_store::{MemorySessionStore, SessionStore as PublicSessionStore};
let store = Arc::new(MemorySessionStore::new());
let store_dyn: Arc<dyn PublicSessionStore> = store.clone();
let session_id = {
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.session_store(store_dyn.clone());
let app = transport.into_router();
let init_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": { "roots": {} },
"clientInfo": {
"name": "original-client",
"version": "3.1.4"
}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(init_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
response
.headers()
.get(MCP_SESSION_ID_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string()
};
let stored = store
.load(&session_id)
.await
.unwrap()
.expect("record should survive transport drop");
assert_eq!(
stored.client_info.as_ref().map(|c| c.name.as_str()),
Some("original-client")
);
let transport2 = HttpTransport::new(create_test_router())
.disable_origin_validation()
.session_store(store_dyn);
let app2 = transport2.into_router();
let list_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header(MCP_SESSION_ID_HEADER, &session_id)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
})
.to_string(),
))
.unwrap();
let response = app2.oneshot(list_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json.get("result").is_some(),
"expected tools/list result, got {json}"
);
let after_restore = store
.load(&session_id)
.await
.unwrap()
.expect("record should still be present after restore");
let client_info = after_restore
.client_info
.expect("restored record should retain client_info");
assert_eq!(client_info.name, "original-client");
assert_eq!(client_info.version, "3.1.4");
assert!(
after_restore.client_capabilities.is_some(),
"restored record should retain client_capabilities"
);
}
#[tokio::test]
async fn test_auto_reinitialize_marks_synthetic_client_info() {
use crate::session_store::{MemorySessionStore, SessionStore as PublicSessionStore};
let store = Arc::new(MemorySessionStore::new());
let store_dyn: Arc<dyn PublicSessionStore> = store.clone();
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.session_store(store_dyn)
.auto_reinitialize_sessions(true);
let app = transport.into_router();
let list_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header(MCP_SESSION_ID_HEADER, "made-up-id")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(list_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let record = store
.load("made-up-id")
.await
.unwrap()
.expect("auto-reinitialize should persist a record");
assert_eq!(
record.client_info.as_ref().map(|c| c.name.as_str()),
Some("auto-recovered")
);
}
#[tokio::test]
async fn test_custom_event_store_buffers_and_purges() {
use crate::event_store::{EventStore as PublicEventStore, MemoryEventStore};
let events = Arc::new(MemoryEventStore::new());
let events_dyn: Arc<dyn PublicEventStore> = events.clone();
let session = Arc::new(Session::new(
create_test_router(),
false,
identity_factory(),
events_dyn,
));
session.buffer_event(0, "first".to_string()).await;
session.buffer_event(1, "second".to_string()).await;
assert_eq!(events.total_events().await, 2);
let replayed = events.replay_after(&session.id, 0).await.unwrap();
assert_eq!(replayed.len(), 1);
assert_eq!(replayed[0].id, 1);
assert_eq!(replayed[0].data, "second");
events.purge_session(&session.id).await.unwrap();
assert_eq!(events.total_events().await, 0);
}
#[tokio::test]
async fn test_restore_from_store_serves_unknown_session_id() {
use crate::session_store::{MemorySessionStore, SessionRecord, SessionStore};
let store = Arc::new(MemorySessionStore::new());
let store_dyn: Arc<dyn SessionStore> = store.clone();
let mut seeded = SessionRecord::new(
"shared-session".to_string(),
"2025-11-25".to_string(),
Duration::from_secs(60),
);
store.create(&mut seeded).await.unwrap();
let seeded_id = seeded.id;
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.session_store(store_dyn);
let app = transport.into_router();
let list_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header(MCP_SESSION_ID_HEADER, &seeded_id)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(list_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json.get("result").is_some(),
"expected tools/list result, got {json}"
);
}
fn test_session_registry() -> SessionRegistry {
SessionRegistry::new(
SessionConfig::default(),
false,
Arc::new(crate::session_store::MemorySessionStore::new()),
Arc::new(crate::event_store::MemoryEventStore::new()),
ServiceSource::Router {
router: create_test_router(),
factory: crate::transport::service::identity_factory(),
},
false,
)
}
#[tokio::test]
async fn test_early_initialized_notification_still_completes_the_handshake() {
let registry = test_session_registry();
let session = registry
.create(
create_test_router().with_fresh_session(),
crate::transport::service::identity_factory(),
)
.await
.expect("session creation");
let SessionServiceSource::Router { router, .. } = &session.service_source else {
panic!("expected a router-backed session");
};
assert_eq!(router.session().phase(), crate::SessionPhase::Uninitialized);
assert!(
router.session().handshake_started(),
"creating a session for an initialize request records the handshake"
);
session.handle_notification(McpNotification::Initialized);
assert!(
router.session().is_initialized(),
"the notification must still complete a handshake that is in flight"
);
assert!(router.session().is_request_allowed("tools/list"));
}
#[tokio::test]
async fn test_initialized_notification_alone_does_not_open_a_session() {
let router = create_test_router().with_fresh_session();
assert!(!router.session().handshake_started());
router.handle_notification(McpNotification::Initialized);
assert_eq!(router.session().phase(), crate::SessionPhase::Uninitialized);
assert!(!router.session().is_request_allowed("tools/list"));
}
#[tokio::test]
async fn test_optional_sessions_still_serves_without_a_handshake() {
let registry = test_session_registry();
let session = registry
.create_initialized(
create_test_router().with_fresh_session(),
crate::transport::service::identity_factory(),
)
.await
.expect("session creation");
let SessionServiceSource::Router { router, .. } = &session.service_source else {
panic!("expected a router-backed session");
};
assert!(router.session().is_initialized());
assert!(router.session().is_request_allowed("tools/list"));
}
#[tokio::test]
async fn test_auto_reinitialize_serves_unknown_session_without_store_record() {
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.auto_reinitialize_sessions(true);
let app = transport.into_router();
let list_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header(MCP_SESSION_ID_HEADER, "client-made-up-id")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(list_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json.get("result").is_some(),
"expected tools/list result, got {json}"
);
}
#[tokio::test]
async fn test_unknown_session_without_restore_or_auto_reinit_returns_error() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let list_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header(MCP_SESSION_ID_HEADER, "never-seen-before")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(list_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("error").is_some(), "expected error, got {json}");
assert_eq!(json["error"]["code"], -32005); }
#[tokio::test]
async fn test_session_expiration() {
let config = SessionConfig::with_ttl(Duration::from_millis(50))
.cleanup_interval(Duration::from_millis(10));
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.session_config(config);
let app = transport.into_router();
let init_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
})
.to_string(),
))
.unwrap();
let response = app.clone().oneshot(init_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let session_id = response
.headers()
.get(MCP_SESSION_ID_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string();
tokio::time::sleep(Duration::from_millis(100)).await;
let list_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header(MCP_SESSION_ID_HEADER, &session_id)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(list_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("error").is_some());
assert_eq!(json["error"]["code"], -32005); }
#[tokio::test]
async fn test_layer_with_identity() {
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.layer(tower::layer::util::Identity::new());
let app = transport.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(response.headers().contains_key(MCP_SESSION_ID_HEADER));
}
#[tokio::test]
async fn test_layer_with_timeout() {
use std::time::Duration;
use tower::timeout::TimeoutLayer;
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.layer(TimeoutLayer::new(Duration::from_secs(30)));
let app = transport.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(response.headers().contains_key(MCP_SESSION_ID_HEADER));
}
#[tokio::test]
async fn test_layer_middleware_error_produces_jsonrpc_error() {
use std::time::Duration;
use tower::timeout::TimeoutLayer;
let slow_tool = crate::tool::ToolBuilder::new("slow")
.description("A slow tool")
.handler(|_: serde_json::Value| async move {
tokio::time::sleep(Duration::from_secs(10)).await;
Ok(crate::CallToolResult::text("done"))
})
.build();
let router = McpRouter::new()
.server_info("test-server", "1.0.0")
.tool(slow_tool);
let transport = HttpTransport::new(router)
.disable_origin_validation()
.layer(TimeoutLayer::new(Duration::from_millis(1)));
let app = transport.into_router();
let init_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
})
.to_string(),
))
.unwrap();
let response = app.clone().oneshot(init_request).await.unwrap();
let session_id = response
.headers()
.get(MCP_SESSION_ID_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string();
let tool_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header(MCP_SESSION_ID_HEADER, &session_id)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "slow",
"arguments": {}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(tool_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json.get("error").is_some(),
"Expected JSON-RPC error response, got: {}",
json
);
}
#[tokio::test]
async fn test_max_sessions_limit() {
let config = SessionConfig::default().max_sessions(1);
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.session_config(config);
let app = transport.into_router();
let init_request1 = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
})
.to_string(),
))
.unwrap();
let response = app.clone().oneshot(init_request1).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let init_request2 = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {
"name": "test-client-2",
"version": "1.0.0"
}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(init_request2).await.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn test_session_event_buffering() {
let session = Session::new(
create_test_router(),
false,
identity_factory(),
Arc::new(crate::event_store::MemoryEventStore::new()),
);
session.buffer_event(0, "event0".to_string()).await;
session.buffer_event(1, "event1".to_string()).await;
session.buffer_event(2, "event2".to_string()).await;
let events = session.get_events_after(0).await;
assert_eq!(events.len(), 2);
assert_eq!(events[0].id, 1);
assert_eq!(events[0].data, "event1");
assert_eq!(events[1].id, 2);
assert_eq!(events[1].data, "event2");
let events = session.get_events_after(1).await;
assert_eq!(events.len(), 1);
assert_eq!(events[0].id, 2);
let events = session.get_events_after(2).await;
assert!(events.is_empty());
}
#[tokio::test]
async fn test_session_event_counter_increments() {
let session = Session::new(
create_test_router(),
false,
identity_factory(),
Arc::new(crate::event_store::MemoryEventStore::new()),
);
assert_eq!(session.next_event_id(), 0);
assert_eq!(session.next_event_id(), 1);
assert_eq!(session.next_event_id(), 2);
}
#[tokio::test]
async fn test_session_event_buffer_limit() {
let session = Session::new(
create_test_router(),
false,
identity_factory(),
Arc::new(crate::event_store::MemoryEventStore::new()),
);
for i in 0..10 {
session.buffer_event(i, format!("event{}", i)).await;
}
let events = session.get_events_after(0).await;
assert_eq!(events.len(), 9);
}
#[tokio::test]
async fn test_session_handle_count() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let (app, handle) = transport.into_router_with_handle();
assert_eq!(handle.session_count().await, 0);
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), 200);
assert_eq!(handle.session_count().await, 1);
}
#[tokio::test]
async fn test_session_handle_list_and_terminate() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let (app, handle) = transport.into_router_with_handle();
assert!(handle.list_sessions().await.is_empty());
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {
"name": "test-client",
"version": "1.0.0"
}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), 200);
let sessions = handle.list_sessions().await;
assert_eq!(sessions.len(), 1);
assert!(!sessions[0].id.is_empty());
let session_id = sessions[0].id.clone();
assert!(handle.terminate_session(&session_id).await);
assert_eq!(handle.session_count().await, 0);
assert!(!handle.terminate_session(&session_id).await);
}
#[tokio::test]
async fn test_request_without_session_id_rejected() {
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.require_sessions();
let app = transport.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK); let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["error"].is_object());
}
#[tokio::test]
async fn test_invalid_session_id_returns_error() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("mcp-session-id", "nonexistent-session-id")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"]["code"].as_i64().unwrap(), -32005); }
#[tokio::test]
async fn test_notification_returns_accepted() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let init_req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "test", "version": "1.0" }
}
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(init_req).await.unwrap();
let session_id = resp
.headers()
.get(MCP_SESSION_ID_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string();
let notif = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("mcp-session-id", &session_id)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(notif).await.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
}
#[tokio::test]
async fn test_invalid_json_returns_parse_error() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.body(Body::from("not valid json{{{"))
.unwrap();
let response = app.oneshot(request).await.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
tower_mcp_types::testing::assert_jsonrpc_error_response(&json);
assert!(
json["id"].is_null(),
"id must be null on parse error: {json}"
);
assert_eq!(json["error"]["code"].as_i64().unwrap(), -32700);
}
#[tokio::test]
async fn test_session_config_max_sessions() {
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.session_config(SessionConfig::default().max_sessions(1));
let app = transport.into_router();
let init1 = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "test1", "version": "1.0" }
}
})
.to_string(),
))
.unwrap();
let resp1 = app.clone().oneshot(init1).await.unwrap();
assert_eq!(resp1.status(), StatusCode::OK);
let init2 = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "test2", "version": "1.0" }
}
})
.to_string(),
))
.unwrap();
let resp2 = app.oneshot(init2).await.unwrap();
assert_eq!(resp2.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn test_delete_terminates_session() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let init_req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "test", "version": "1.0" }
}
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(init_req).await.unwrap();
let session_id = resp
.headers()
.get(MCP_SESSION_ID_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string();
let delete_req = Request::builder()
.method("DELETE")
.uri("/")
.header("mcp-session-id", &session_id)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(delete_req).await.unwrap();
assert!(resp.status().is_success());
let list_req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("mcp-session-id", &session_id)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
})
.to_string(),
))
.unwrap();
let resp = app.oneshot(list_req).await.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"]["code"].as_i64().unwrap(), -32005);
}
#[test]
fn test_is_localhost_origin_http() {
assert!(is_localhost_origin("http://localhost"));
assert!(is_localhost_origin("http://localhost:3000"));
assert!(is_localhost_origin("http://127.0.0.1"));
assert!(is_localhost_origin("http://127.0.0.1:8080"));
assert!(is_localhost_origin("http://[::1]"));
assert!(is_localhost_origin("http://[::1]:3000"));
}
#[test]
fn test_is_localhost_origin_https() {
assert!(is_localhost_origin("https://localhost"));
assert!(is_localhost_origin("https://127.0.0.1:443"));
}
#[test]
fn test_is_localhost_origin_case_insensitive_scheme() {
assert!(is_localhost_origin("HTTP://localhost"));
assert!(is_localhost_origin("Https://127.0.0.1"));
assert!(is_localhost_origin("http://LOCALHOST"));
}
#[test]
fn test_is_localhost_origin_trailing_dot_fqdn() {
assert!(is_localhost_origin("http://localhost."));
assert!(is_localhost_origin("http://localhost.:3000"));
}
#[test]
fn test_is_not_localhost_origin() {
assert!(!is_localhost_origin("http://example.com"));
assert!(!is_localhost_origin("http://evil-localhost.com"));
assert!(!is_localhost_origin("http://localhost.evil.com"));
assert!(!is_localhost_origin("ftp://localhost"));
assert!(!is_localhost_origin("localhost"));
assert!(!is_localhost_origin(""));
}
#[test]
fn test_is_not_localhost_origin_embedded_loopback_strings() {
assert!(!is_localhost_origin("http://127.0.0.1.evil.com"));
assert!(!is_localhost_origin("http://notlocalhost"));
assert!(!is_localhost_origin("http://evil.com#localhost"));
}
#[test]
fn test_is_not_localhost_origin_bracketed_ipv6_with_suffix() {
assert!(!is_localhost_origin("http://[::1]evil.com"));
assert!(!is_localhost_origin("http://[::1]@evil.com"));
assert!(!is_localhost_origin("http://[::1].evil.com"));
}
#[tokio::test]
async fn test_origin_validation_rejects_cross_origin() {
let transport = HttpTransport::new(create_test_router());
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("Origin", "http://evil.com")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "test", "version": "1.0" }
}
})
.to_string(),
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_origin_validation_allows_localhost() {
let transport = HttpTransport::new(create_test_router());
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("Origin", "http://localhost:3000")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "test", "version": "1.0" }
}
})
.to_string(),
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_origin_validation_allows_configured_origin() {
let transport = HttpTransport::new(create_test_router())
.allowed_origins(vec!["https://my-app.example.com".to_string()]);
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("Origin", "https://my-app.example.com")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "test", "version": "1.0" }
}
})
.to_string(),
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_origin_validation_rejects_unconfigured_origin() {
let transport = HttpTransport::new(create_test_router())
.allowed_origins(vec!["https://my-app.example.com".to_string()]);
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("Origin", "https://other-app.example.com")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "test", "version": "1.0" }
}
})
.to_string(),
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn test_origin_validation_no_header_allowed() {
let transport = HttpTransport::new(create_test_router());
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "test", "version": "1.0" }
}
})
.to_string(),
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_disabled_origin_validation_allows_any() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("Origin", "http://evil.com")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "test", "version": "1.0" }
}
})
.to_string(),
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
fn initialize_body() -> Body {
Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "test", "version": "1.0" }
}
})
.to_string(),
)
}
#[test]
fn test_is_localhost_host_variants() {
assert!(is_localhost_host("localhost"));
assert!(is_localhost_host("localhost:3000"));
assert!(is_localhost_host("127.0.0.1"));
assert!(is_localhost_host("127.0.0.1:8080"));
assert!(is_localhost_host("[::1]"));
assert!(is_localhost_host("[::1]:3000"));
assert!(!is_localhost_host("evil.com"));
assert!(!is_localhost_host("api.example.com:8443"));
assert!(!is_localhost_host("10.0.0.1"));
}
#[test]
fn test_is_localhost_host_case_insensitive() {
assert!(is_localhost_host("LOCALHOST"));
assert!(is_localhost_host("LOCALHOST:3000"));
assert!(is_localhost_host("Localhost"));
}
#[test]
fn test_is_localhost_host_full_loopback_range() {
assert!(is_localhost_host("127.0.0.2"));
assert!(is_localhost_host("127.1.2.3"));
assert!(is_localhost_host("127.255.255.255"));
assert!(is_localhost_host("127.0.0.2:8080"));
}
#[test]
fn test_is_localhost_host_trailing_dot_fqdn() {
assert!(is_localhost_host("localhost."));
assert!(is_localhost_host("localhost.:3000"));
}
#[test]
fn test_is_localhost_host_bare_ipv6() {
assert!(is_localhost_host("::1"));
}
#[test]
fn test_is_not_localhost_host_embedded_loopback_strings() {
assert!(!is_localhost_host("localhost.evil.com"));
assert!(!is_localhost_host("127.0.0.1.evil.com"));
assert!(!is_localhost_host("notlocalhost"));
assert!(!is_localhost_host("evil.com#localhost"));
}
#[test]
fn test_is_not_localhost_host_non_canonical_ipv4_forms() {
assert!(!is_localhost_host("0x7f.0.0.1"));
assert!(!is_localhost_host("017700000001"));
assert!(!is_localhost_host("2130706433"));
assert!(!is_localhost_host("127.1"));
}
#[test]
fn test_is_not_localhost_host_bare_ipv6_with_trailing_segment() {
assert!(!is_localhost_host("::1:3000"));
}
#[test]
fn test_is_not_localhost_host_bracketed_ipv6_with_suffix() {
assert!(!is_localhost_host("[::1]evil.com"));
assert!(!is_localhost_host("[::1]@evil.com"));
assert!(!is_localhost_host("[::1].evil.com"));
assert!(!is_localhost_host("[::1]]evil.com"));
assert!(!is_localhost_host("[::1"));
}
#[test]
fn test_is_not_localhost_host_bracketed_ipv6_non_loopback_with_suffix() {
assert!(!is_localhost_host("[2001:db8::1]evil.com"));
}
#[test]
fn test_is_localhost_host_bracketed_ipv6_port_boundary() {
assert!(is_localhost_host("[::1]"));
assert!(is_localhost_host("[::1]:3000"));
assert!(is_localhost_host("[::1]:0"));
assert!(is_localhost_host("[::1]:65535"));
}
#[test]
fn test_is_not_localhost_host_bracketed_ipv6_invalid_port() {
assert!(!is_localhost_host("[::1]:"));
assert!(!is_localhost_host("[::1]:notaport"));
assert!(!is_localhost_host("[::1]:99999"));
assert!(!is_localhost_host("[::1]:65536"));
}
#[tokio::test]
async fn test_host_validation_allows_localhost() {
let transport =
HttpTransport::new(create_test_router()).allowed_hosts(vec!["api.example.com".to_string()]);
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("Host", "127.0.0.1:3000")
.body(initialize_body())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_host_validation_allows_configured_host() {
let transport =
HttpTransport::new(create_test_router()).allowed_hosts(vec!["api.example.com".to_string()]);
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("Host", "api.example.com")
.body(initialize_body())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_host_validation_rejects_unconfigured_host() {
let transport =
HttpTransport::new(create_test_router()).allowed_hosts(vec!["api.example.com".to_string()]);
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("Host", "evil.com")
.body(initialize_body())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_host_validation_no_allowlist_accepts_any_host() {
let transport = HttpTransport::new(create_test_router());
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("Host", "any.example.com")
.body(initialize_body())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_disabled_host_validation_allows_any_with_allowlist() {
let transport = HttpTransport::new(create_test_router())
.disable_host_validation()
.allowed_hosts(vec!["api.example.com".to_string()]);
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("Host", "evil.com")
.body(initialize_body())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[test]
fn test_effective_host_prefers_header() {
let mut headers = HeaderMap::new();
headers.insert(header::HOST, HeaderValue::from_static("api.example.com"));
let uri: axum::http::Uri = "http://other.example.com/path".parse().unwrap();
assert_eq!(effective_host(&headers, &uri), Some("api.example.com"));
}
#[test]
fn test_effective_host_falls_back_to_authority() {
let headers = HeaderMap::new();
let uri: axum::http::Uri = "http://api.example.com/path".parse().unwrap();
assert_eq!(effective_host(&headers, &uri), Some("api.example.com"));
}
#[test]
fn test_effective_host_returns_none_when_both_missing() {
let headers = HeaderMap::new();
let uri: axum::http::Uri = "/path".parse().unwrap();
assert_eq!(effective_host(&headers, &uri), None);
}
async fn init_session(app: &Router) -> String {
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "test", "version": "1.0" }
}
})
.to_string(),
))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
resp.headers()
.get(MCP_SESSION_ID_HEADER)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.expect("initialize must return a session id")
}
#[tokio::test]
async fn test_external_notification_reaches_single_session() {
let (notif_tx, notif_rx) = notification_channel(8);
let transport = HttpTransport::with_notifications(create_test_router(), notif_rx);
let (app, session_handle) = transport.into_router_with_handle();
let session_id = init_session(&app).await;
let mut rx = {
let sessions = session_handle.store.sessions.read().await;
let session = sessions
.get(&session_id)
.expect("session should be registered");
session.notifications_tx.subscribe()
};
notif_tx
.send(crate::context::ServerNotification::ResourceUpdated {
uri: "claude://chats/abc".to_string(),
})
.await
.unwrap();
let json = tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await
.expect("notification should arrive within timeout")
.expect("broadcast channel closed");
assert!(json.contains("notifications/resources/updated"));
assert!(json.contains("claude://chats/abc"));
}
#[tokio::test]
async fn test_external_notification_fans_out_to_all_sessions() {
let (notif_tx, notif_rx) = notification_channel(8);
let transport = HttpTransport::with_notifications(create_test_router(), notif_rx);
let (app, session_handle) = transport.into_router_with_handle();
let session_a = init_session(&app).await;
let session_b = init_session(&app).await;
assert_ne!(session_a, session_b);
let (mut rx_a, mut rx_b) = {
let sessions = session_handle.store.sessions.read().await;
let a = sessions.get(&session_a).unwrap();
let b = sessions.get(&session_b).unwrap();
(
a.notifications_tx.subscribe(),
b.notifications_tx.subscribe(),
)
};
notif_tx
.send(crate::context::ServerNotification::ResourcesListChanged)
.await
.unwrap();
let json_a = tokio::time::timeout(Duration::from_secs(1), rx_a.recv())
.await
.unwrap()
.unwrap();
let json_b = tokio::time::timeout(Duration::from_secs(1), rx_b.recv())
.await
.unwrap()
.unwrap();
assert!(json_a.contains("notifications/resources/list_changed"));
assert!(json_b.contains("notifications/resources/list_changed"));
}
#[tokio::test]
async fn test_external_notifications_builder_method() {
let (notif_tx, notif_rx) = notification_channel(8);
let transport = HttpTransport::new(create_test_router()).external_notifications(notif_rx);
let (app, session_handle) = transport.into_router_with_handle();
let session_id = init_session(&app).await;
let mut rx = {
let sessions = session_handle.store.sessions.read().await;
sessions
.get(&session_id)
.unwrap()
.notifications_tx
.subscribe()
};
notif_tx
.send(crate::context::ServerNotification::ToolsListChanged)
.await
.unwrap();
let json = tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await
.unwrap()
.unwrap();
assert!(json.contains("notifications/tools/list_changed"));
}
#[tokio::test]
async fn test_default_transport_has_no_external_fanout_task() {
let transport = HttpTransport::new(create_test_router());
let (app, _handle) = transport.into_router_with_handle();
let _session_id = init_session(&app).await;
}
#[tokio::test]
#[cfg(feature = "stateless")]
async fn stateless_v2026_initialize_is_method_not_found() {
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.disable_host_validation();
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header(MCP_METHOD_HEADER, "initialize")
.header(MCP_PROTOCOL_VERSION_HEADER, PROTOCOL_VERSION_2026_07_28)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2026-07-28",
"capabilities": {},
"clientInfo": { "name": "sc", "version": "1.0" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert!(
!response.headers().contains_key(MCP_SESSION_ID_HEADER),
"removed final method must not create a session"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["id"], 1);
assert_eq!(json["error"]["code"], ErrorCode::MethodNotFound.code());
}
#[tokio::test]
#[cfg(feature = "stateless")]
async fn stateless_v2026_rejects_missing_required_meta_with_http_400() {
let app = HttpTransport::new(create_test_router())
.disable_origin_validation()
.disable_host_validation()
.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_METHOD_HEADER, "server/discover")
.header(MCP_PROTOCOL_VERSION_HEADER, PROTOCOL_VERSION_2026_07_28)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 101,
"method": "server/discover",
"params": {}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["id"], 101);
assert_eq!(json["error"]["code"], ErrorCode::InvalidParams.code());
}
#[tokio::test]
#[cfg(feature = "stateless")]
async fn stateless_v2026_rejects_invalid_meta_and_extension_keys_with_http_400() {
let app = HttpTransport::new(create_test_router())
.disable_origin_validation()
.disable_host_validation()
.into_router();
let build_request = |id: i64, extra_meta: serde_json::Value, extensions: serde_json::Value| {
let mut meta = serde_json::json!({
"io.modelcontextprotocol/protocolVersion": PROTOCOL_VERSION_2026_07_28,
"io.modelcontextprotocol/clientCapabilities": {
"extensions": extensions
}
});
meta.as_object_mut()
.unwrap()
.extend(extra_meta.as_object().unwrap().clone());
Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_METHOD_HEADER, "server/discover")
.header(MCP_PROTOCOL_VERSION_HEADER, PROTOCOL_VERSION_2026_07_28)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "server/discover",
"params": { "_meta": meta }
})
.to_string(),
))
.unwrap()
};
for request in [
build_request(
111,
serde_json::json!({"com.example/-invalid": true}),
serde_json::json!({}),
),
build_request(
112,
serde_json::json!({}),
serde_json::json!({"unprefixed": {}}),
),
build_request(
113,
serde_json::json!({}),
serde_json::json!({"com.example/feature": true}),
),
] {
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"]["code"], ErrorCode::InvalidParams.code());
}
}
#[tokio::test]
#[cfg(feature = "stateless")]
async fn stateless_v2026_rejects_missing_protocol_header_with_http_400() {
let app = HttpTransport::new(create_test_router())
.disable_origin_validation()
.disable_host_validation()
.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_METHOD_HEADER, "server/discover")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 102,
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["id"], 102);
assert_eq!(json["error"]["code"], McpErrorCode::HeaderMismatch.code());
}
#[tokio::test]
#[cfg(feature = "stateless")]
async fn stateless_v2026_unknown_method_is_http_404() {
let app = HttpTransport::new(create_test_router())
.disable_origin_validation()
.disable_host_validation()
.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_METHOD_HEADER, "unknown/method")
.header(MCP_PROTOCOL_VERSION_HEADER, PROTOCOL_VERSION_2026_07_28)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 103,
"method": "unknown/method",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["id"], 103);
assert_eq!(json["error"]["code"], ErrorCode::MethodNotFound.code());
}
#[tokio::test]
#[cfg(feature = "stateless")]
async fn stateless_v2026_ignores_legacy_session_and_resumption_headers() {
let app = HttpTransport::new(create_test_router())
.disable_origin_validation()
.disable_host_validation()
.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_METHOD_HEADER, "tools/list")
.header(MCP_PROTOCOL_VERSION_HEADER, PROTOCOL_VERSION_2026_07_28)
.header(MCP_SESSION_ID_HEADER, "legacy-session-that-does-not-exist")
.header(LAST_EVENT_ID_HEADER, "legacy-event")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 104,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(!response.headers().contains_key(MCP_SESSION_ID_HEADER));
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["result"]["tools"].is_array());
}
#[tokio::test]
#[cfg(feature = "stateless")]
async fn stateless_v2026_enforces_tool_client_capability_requirements() {
use crate::{CallToolResult, SamplingCapability, ToolBuilder};
let tool = ToolBuilder::new("sample")
.no_params_handler(|| async { Ok(CallToolResult::text("ok")) })
.build()
.require_client_capabilities(ClientCapabilities {
sampling: Some(SamplingCapability::default()),
..ClientCapabilities::default()
});
let router = McpRouter::new()
.server_info("test-server", "1.0.0")
.tool(tool);
let app = HttpTransport::new(router)
.disable_origin_validation()
.disable_host_validation()
.into_router();
let build_request = |id: i64, capabilities: serde_json::Value| {
Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_METHOD_HEADER, "tools/call")
.header(MCP_NAME_HEADER, "sample")
.header(MCP_PROTOCOL_VERSION_HEADER, PROTOCOL_VERSION_2026_07_28)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": {
"name": "sample",
"arguments": {},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": capabilities
}
}
})
.to_string(),
))
.unwrap()
};
let response = app
.clone()
.oneshot(build_request(105, serde_json::json!({})))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["id"], 105);
assert_eq!(
json["error"]["code"],
McpErrorCode::MissingRequiredClientCapability.code()
);
assert_eq!(
json["error"]["data"]["requiredCapabilities"],
serde_json::json!({ "sampling": {} })
);
let response = app
.oneshot(build_request(106, serde_json::json!({ "sampling": {} })))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["result"]["content"][0]["text"], "ok");
}
#[tokio::test]
#[cfg(feature = "stateless")]
async fn stateless_v2026_tools_call_without_session_succeeds() {
use crate::{CallToolResult, ToolBuilder};
let router = McpRouter::new().server_info("t", "1.0.0").tool(
ToolBuilder::new("echo")
.description("echo")
.handler(
|args: serde_json::Value| async move { Ok(CallToolResult::text(args.to_string())) },
)
.build(),
);
let transport = HttpTransport::new(router).disable_origin_validation();
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_PROTOCOL_VERSION_HEADER, "2026-07-28")
.header(MCP_METHOD_HEADER, "tools/call")
.header(MCP_NAME_HEADER, "echo")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "echo",
"arguments": {"message": "hello"},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "sc", "version": "1.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
!response.headers().contains_key(MCP_SESSION_ID_HEADER),
"stateless tools/call must not set mcp-session-id"
);
assert_eq!(
response
.headers()
.get(MCP_PROTOCOL_VERSION_HEADER)
.and_then(|v| v.to_str().ok()),
Some("2026-07-28")
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json.get("result").is_some(),
"expected tools/call result, got: {json}"
);
assert_eq!(json["result"]["resultType"], "complete");
}
#[cfg(feature = "stateless")]
fn stateless_tools_call_request() -> Request<Body> {
Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_PROTOCOL_VERSION_HEADER, "2026-07-28")
.header(MCP_METHOD_HEADER, "tools/call")
.header(MCP_NAME_HEADER, "echo")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "echo",
"arguments": {"message": "hello"},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {
"name": "sc", "version": "1.0"
},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
})
.to_string(),
))
.unwrap()
}
#[cfg(feature = "stateless")]
fn echo_router() -> McpRouter {
use crate::{CallToolResult, ToolBuilder};
McpRouter::new().server_info("t", "1.0.0").tool(
ToolBuilder::new("echo")
.description("echo")
.handler(
|args: serde_json::Value| async move { Ok(CallToolResult::text(args.to_string())) },
)
.build(),
)
}
#[tokio::test]
#[cfg(feature = "stateless")]
async fn stateless_v2026_response_stamps_server_info_by_default() {
let transport = HttpTransport::new(echo_router()).disable_origin_validation();
let app = transport.into_router();
let response = app.oneshot(stateless_tools_call_request()).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
json["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], "t",
"expected serverInfo stamped into result._meta, got: {json}"
);
assert_eq!(
json["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["version"],
"1.0.0"
);
}
#[tokio::test]
#[cfg(feature = "stateless")]
async fn stateless_v2026_response_omits_server_info_when_disabled() {
let transport = HttpTransport::new(echo_router())
.disable_origin_validation()
.stamp_server_info(false);
let app = transport.into_router();
let response = app.oneshot(stateless_tools_call_request()).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json["result"].get("_meta").is_none(),
"expected no _meta when stamping is disabled, got: {json}"
);
}
#[tokio::test]
async fn stateless_v2025_initialize_still_gets_session_id() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "old-client", "version": "1.0" }
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
response.headers().contains_key(MCP_SESSION_ID_HEADER),
"2025-11-25 initialize must return mcp-session-id"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json["result"].get("resultType").is_none(),
"legacy result must remain unchanged: {json}"
);
}
#[tokio::test]
async fn stateless_v2025_tools_list_without_session_rejected() {
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.require_sessions();
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_PROTOCOL_VERSION_HEADER, "2025-11-25")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/list"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("error").is_some(), "expected error, got: {json}");
assert_eq!(
json["error"]["code"].as_i64().unwrap(),
-32006,
"expected SessionRequired (-32006)"
);
}
#[tokio::test]
#[cfg(feature = "stateless")]
async fn stateless_v2026_tools_list_without_session_succeeds() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_PROTOCOL_VERSION_HEADER, "2026-07-28")
.header(MCP_METHOD_HEADER, "tools/list")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
!response.headers().contains_key(MCP_SESSION_ID_HEADER),
"stateless tools/list must not set mcp-session-id"
);
assert_eq!(
response
.headers()
.get(MCP_PROTOCOL_VERSION_HEADER)
.and_then(|v| v.to_str().ok()),
Some("2026-07-28")
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json["result"]["tools"].is_array(),
"expected tools array in result, got: {json}"
);
assert_eq!(json["result"]["resultType"], "complete");
assert_eq!(json["result"]["ttlMs"], 0);
assert_eq!(json["result"]["cacheScope"], "private");
}
#[tokio::test]
#[cfg(feature = "stateless")]
async fn stateless_v2026_notification_returns_202_no_session() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let (app, handle) = transport.into_router_with_handle();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_PROTOCOL_VERSION_HEADER, "2026-07-28")
.header(MCP_METHOD_HEADER, "notifications/cancelled")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": {
"requestId": 99,
"reason": "test",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(
response.status(),
StatusCode::ACCEPTED,
"stateless notification must return 202 ACCEPTED"
);
assert!(
!response.headers().contains_key(MCP_SESSION_ID_HEADER),
"stateless notification must not set mcp-session-id"
);
assert_eq!(
handle.session_count().await,
0,
"stateless notification must not create a session"
);
}
#[tokio::test]
#[cfg(feature = "stateless")]
async fn stateless_v2026_missing_mcp_method_returns_400() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let req = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_PROTOCOL_VERSION_HEADER, "2026-07-28")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"missing Mcp-Method must return HTTP 400"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.get("error").is_some(), "expected error, got: {json}");
assert_eq!(
json["error"]["code"].as_i64().unwrap(),
-32020,
"expected HeaderMismatch (-32020)"
);
assert!(
json["error"]["message"]
.as_str()
.unwrap_or("")
.contains("Mcp-Method"),
"error message must mention Mcp-Method, got: {json}"
);
}
#[tokio::test]
async fn sse_responses_false_returns_application_json() {
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.sse_responses(false);
let app = transport.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "test", "version": "0.1"}
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let ct = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
ct.contains("application/json"),
"sse_responses(false) should return application/json, got: {ct}"
);
}
#[tokio::test]
async fn sse_responses_true_returns_text_event_stream_with_valid_json() {
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.sse_responses(true);
let app = transport.into_router();
let init_body = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "test", "version": "0.1"}
}
})
.to_string();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(init_body))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let ct = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
ct.contains("text/event-stream"),
"sse_responses(true) should return text/event-stream, got: {ct}"
);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body_text = String::from_utf8_lossy(&bytes);
assert!(
body_text.contains("event: message"),
"SSE body missing 'event: message': {body_text}"
);
assert!(
body_text.contains("data: "),
"SSE body missing 'data: ' line: {body_text}"
);
let data_line = body_text
.lines()
.find(|l| l.starts_with("data: "))
.expect("no data: line in SSE body");
let json_str = data_line.trim_start_matches("data: ");
let val: serde_json::Value =
serde_json::from_str(json_str).expect("data: line is not valid JSON");
assert_eq!(val["jsonrpc"], "2.0", "jsonrpc version mismatch: {val}");
assert_eq!(val["id"], 1, "id mismatch: {val}");
assert!(
val["result"].is_object(),
"result should be an object: {val}"
);
assert_eq!(
val["result"]["protocolVersion"].as_str(),
Some("2025-11-25"),
"protocolVersion missing or wrong: {val}"
);
}
#[tokio::test]
async fn sse_responses_true_tools_list_returns_valid_sse() {
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.sse_responses(true);
let app = transport.into_router();
let init_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "test", "version": "0.1"}
}
})
.to_string(),
))
.unwrap();
let init_response = app.clone().oneshot(init_request).await.unwrap();
assert_eq!(init_response.status(), StatusCode::OK);
let session_id = init_response
.headers()
.get(MCP_SESSION_ID_HEADER)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.expect("missing session ID from initialize");
let notif_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header(MCP_SESSION_ID_HEADER, &session_id)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
})
.to_string(),
))
.unwrap();
app.clone().oneshot(notif_request).await.unwrap();
let list_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header(MCP_SESSION_ID_HEADER, &session_id)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
})
.to_string(),
))
.unwrap();
let list_response = app.oneshot(list_request).await.unwrap();
assert_eq!(list_response.status(), StatusCode::OK);
let ct = list_response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert!(
ct.contains("text/event-stream"),
"tools/list with sse_responses(true) should return text/event-stream, got: {ct}"
);
let bytes = axum::body::to_bytes(list_response.into_body(), usize::MAX)
.await
.unwrap();
let body_text = String::from_utf8_lossy(&bytes);
let data_line = body_text
.lines()
.find(|l| l.starts_with("data: "))
.expect("no data: line in SSE body for tools/list");
let json_str = data_line.trim_start_matches("data: ");
let val: serde_json::Value =
serde_json::from_str(json_str).expect("tools/list data: line is not valid JSON");
assert_eq!(val["jsonrpc"], "2.0");
assert_eq!(val["id"], 2);
assert!(
val["result"]["tools"].is_array(),
"tools/list result.tools should be an array: {val}"
);
}
async fn do_initialize(app: &axum::Router) -> String {
do_initialize_for_revision(app, "2025-11-25").await
}
async fn do_initialize_for_revision(app: &axum::Router, revision: &str) -> String {
let init_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": revision,
"capabilities": {},
"clientInfo": { "name": "test-client", "version": "1.0.0" }
}
})
.to_string(),
))
.unwrap();
let response = app.clone().oneshot(init_request).await.unwrap();
response
.headers()
.get(MCP_SESSION_ID_HEADER)
.unwrap()
.to_str()
.unwrap()
.to_string()
}
async fn send_initialized(app: &axum::Router, session_id: &str) {
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header(MCP_SESSION_ID_HEADER, session_id)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
})
.to_string(),
))
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
}
async fn post_legacy_batch(app: &axum::Router, session_id: &str) -> serde_json::Value {
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_SESSION_ID_HEADER, session_id)
.body(Body::from(
serde_json::json!([
{"jsonrpc": "2.0", "id": 2, "method": "ping"},
{"jsonrpc": "2.0", "id": 3, "method": "tools/list"}
])
.to_string(),
))
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
serde_json::from_slice(&body).unwrap()
}
#[tokio::test]
async fn http_batch_policy_uses_exact_session_revision() {
let march_app = HttpTransport::new(create_test_router())
.disable_origin_validation()
.protocol_versions(["2025-03-26"])
.unwrap()
.into_router();
let march_session = do_initialize_for_revision(&march_app, "2025-03-26").await;
send_initialized(&march_app, &march_session).await;
let march_response = post_legacy_batch(&march_app, &march_session).await;
assert_eq!(march_response.as_array().map(Vec::len), Some(2));
let november_app = HttpTransport::new(create_test_router())
.disable_origin_validation()
.into_router();
let november_session = do_initialize(&november_app).await;
send_initialized(&november_app, &november_session).await;
let november_response = post_legacy_batch(&november_app, &november_session).await;
assert_eq!(november_response["error"]["code"], -32600);
assert!(
november_response["error"]["message"]
.as_str()
.unwrap()
.contains("does not permit top-level JSON-RPC batches")
);
}
#[cfg(feature = "stateless")]
#[tokio::test]
async fn http_final_batch_is_rejected_before_object_routing() {
let app = HttpTransport::new(create_test_router())
.disable_origin_validation()
.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header(MCP_PROTOCOL_VERSION_HEADER, PROTOCOL_VERSION_2026_07_28)
.body(Body::from(
serde_json::json!([{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}])
.to_string(),
))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let response: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(response["error"]["code"], -32600);
assert!(
response["error"]["message"]
.as_str()
.unwrap()
.contains("does not permit top-level JSON-RPC batches")
);
}
#[tokio::test]
async fn tools_list_before_initialized_notification_returns_error() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let session_id = do_initialize(&app).await;
let list_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header(MCP_SESSION_ID_HEADER, &session_id)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(list_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json.get("error").is_some(),
"expected error when notifications/initialized not sent, got: {json}"
);
assert_eq!(
json["error"]["code"].as_i64().unwrap(),
-32600,
"expected InvalidRequest (-32600), got: {json}"
);
assert!(
json["error"]["message"]
.as_str()
.unwrap_or("")
.contains("notifications/initialized"),
"error message should mention notifications/initialized, got: {json}"
);
}
#[tokio::test]
async fn tools_list_after_initialized_notification_succeeds() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let session_id = do_initialize(&app).await;
let notif_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header(MCP_SESSION_ID_HEADER, &session_id)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
})
.to_string(),
))
.unwrap();
app.clone().oneshot(notif_request).await.unwrap();
let list_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header(MCP_SESSION_ID_HEADER, &session_id)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(list_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json.get("result").is_some(),
"expected success after notifications/initialized, got: {json}"
);
}
#[tokio::test]
async fn notifications_initialized_itself_always_accepted() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let session_id = do_initialize(&app).await;
let notif_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header(MCP_SESSION_ID_HEADER, &session_id)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(notif_request).await.unwrap();
assert_eq!(
response.status(),
StatusCode::ACCEPTED,
"notifications/initialized must return 202 ACCEPTED"
);
}
#[tokio::test]
async fn strict_initialization_false_allows_tools_list_without_notification() {
let config = SessionConfig {
strict_initialization: false,
..Default::default()
};
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.session_config(config);
let app = transport.into_router();
let session_id = do_initialize(&app).await;
let list_request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header(MCP_SESSION_ID_HEADER, &session_id)
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
})
.to_string(),
))
.unwrap();
let response = app.oneshot(list_request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json.get("result").is_some(),
"expected success with strict_initialization=false, got: {json}"
);
}
#[tokio::test]
async fn post_body_with_leading_bom_is_served() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let body = format!(
"\u{feff}{}",
serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})
);
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(body))
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["id"], serde_json::json!(1), "BOM'd request: {json}");
assert!(
json.get("result").is_some(),
"BOM'd body must be parsed and served, not rejected: {json}"
);
}
#[tokio::test]
#[cfg(feature = "stateless")]
async fn stateless_malformed_request_with_readable_id_gets_a_matching_id() {
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.stateless(crate::stateless::StatelessConfig::new());
let app = transport.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("MCP-Protocol-Version", "2025-11-25")
.body(Body::from(r#"{"jsonrpc":"2.0","id":7,"method":42}"#))
.unwrap();
let response = app.oneshot(request).await.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
json["id"],
serde_json::json!(7),
"the id was present and readable, so it must be echoed back: {json}"
);
assert_eq!(json["error"]["code"].as_i64().unwrap(), -32700);
let message = json["error"]["message"].as_str().unwrap();
assert!(
!message.contains("JsonRpcRequest") && !message.to_lowercase().contains("struct"),
"error message must not name the Rust type (#1284 precedent): {message}"
);
}
#[tokio::test]
#[cfg(feature = "stateless")]
async fn stateless_malformed_request_with_unusable_id_stays_null() {
let transport = HttpTransport::new(create_test_router())
.disable_origin_validation()
.stateless(crate::stateless::StatelessConfig::new());
let app = transport.into_router();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("MCP-Protocol-Version", "2025-11-25")
.body(Body::from(r#"{"jsonrpc":"2.0","id":null,"method":42}"#))
.unwrap();
let response = app.oneshot(request).await.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(
json["id"].is_null(),
"no usable id was available, so the response id must be null: {json}"
);
assert_eq!(json["error"]["code"].as_i64().unwrap(), -32700);
}
#[tokio::test]
async fn legacy_initialize_with_malformed_envelope_gets_a_matching_id() {
let transport = HttpTransport::new(create_test_router()).disable_origin_validation();
let app = transport.into_router();
let body = serde_json::json!({
"jsonrpc": 2.0,
"id": 7,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": { "name": "t", "version": "1" }
}
})
.to_string();
let request = Request::builder()
.method("POST")
.uri("/")
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.body(Body::from(body))
.unwrap();
let response = app.oneshot(request).await.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
json["id"],
serde_json::json!(7),
"the id was present and readable, so it must be echoed back: {json}"
);
assert_eq!(json["error"]["code"].as_i64().unwrap(), -32700);
let message = json["error"]["message"].as_str().unwrap();
assert!(
!message.contains("JsonRpcRequest") && !message.to_lowercase().contains("struct"),
"error message must not name the Rust type (#1284 precedent): {message}"
);
}