#![cfg(all(
not(feature = "legacy-spec"),
feature = "http-server-volga",
feature = "http-client",
feature = "tracing"
))]
use neva::App;
use neva::types::notification;
use tracing_subscriber::prelude::*;
const MARKER: &str = "distinctive-log-marker";
const MARKER_A: &str = "marker-alpha";
const MARKER_B: &str = "marker-bravo";
const MARKER_AFTER_NEXT: &str = "marker-after-next";
async fn body_within(resp: reqwest::Response, what: &str) -> String {
tokio::time::timeout(std::time::Duration::from_secs(5), resp.text())
.await
.unwrap_or_else(|_| panic!("{what}: SSE body did not close within 5s"))
.expect("body read failed")
}
#[tokio::test(flavor = "multi_thread")]
async fn request_scoped_logging_streams_over_post() {
tracing_subscriber::registry()
.with(tracing::level_filters::LevelFilter::WARN)
.with(notification::fmt::layer())
.init();
let port = pick_free_port();
let addr = format!("127.0.0.1:{port}");
let mut app = App::new()
.with_options(|opt| opt.with_http(|http| http.bind(&addr).with_endpoint("/mcp")))
.wrap(|ctx, next| async move {
let resp = next(ctx).await;
tracing::warn!(logger = "mw", "{MARKER_AFTER_NEXT}");
resp
});
app.map_tool("shout", || async move {
tracing::warn!(logger = "tool", "{MARKER}");
"pong".to_string()
});
app.map_tool("shout_a", || async move {
tracing::warn!(logger = "tool", "{MARKER_A}");
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
"a".to_string()
});
app.map_tool("shout_b", || async move {
tracing::warn!(logger = "tool", "{MARKER_B}");
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
"b".to_string()
});
let handle = tokio::spawn(async move { app.run().await });
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
let client = reqwest::Client::builder()
.no_proxy()
.build()
.expect("test client");
let url = format!("http://{addr}/mcp");
let call = serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {
"name": "shout",
"arguments": {},
"_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/logLevel": "info" }
}
});
let resp = routed(client.post(&url), &call)
.header("Accept", "application/json, text/event-stream")
.json(&call)
.send()
.await
.expect("logged call failed");
assert!(resp.status().is_success());
let ctype = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_owned();
assert!(
ctype.contains("text/event-stream"),
"opted-in reply must be an SSE stream, got content-type {ctype:?}"
);
let body = body_within(resp, "opted-in call").await;
assert!(
body.contains("notifications/message"),
"SSE body must carry the log notification, got: {body}"
);
assert!(
body.contains(MARKER),
"SSE body must carry the log message, got: {body}"
);
assert!(
body.contains("pong"),
"SSE body must carry the response, got: {body}"
);
assert!(
body.contains(MARKER_AFTER_NEXT),
"SSE body must carry the after-next middleware log, got: {body}"
);
let resp_at = body.find("\"result\"").expect("response in body");
assert!(
body.find(MARKER).is_some_and(|at| at < resp_at)
&& body.find(MARKER_AFTER_NEXT).is_some_and(|at| at < resp_at),
"notifications must precede the final response, got: {body}"
);
let call = serde_json::json!({
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "name": "shout", "arguments": {}, "_meta": meta() }
});
let resp = routed(client.post(&url), &call)
.header("Accept", "application/json, text/event-stream")
.json(&call)
.send()
.await
.expect("plain call failed");
assert!(resp.status().is_success());
let ctype = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_owned();
assert!(
!ctype.contains("text/event-stream"),
"reply without logLevel must be plain JSON, got content-type {ctype:?}"
);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(
body.pointer("/result/content/0/text")
.and_then(|v| v.as_str()),
Some("pong")
);
let batch = serde_json::json!([
{
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {
"name": "shout",
"arguments": {},
"_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/logLevel": "info" }
}
},
{
"jsonrpc": "2.0", "id": 4, "method": "tools/call",
"params": { "name": "shout", "arguments": {}, "_meta": meta() }
}
]);
let resp = routed(client.post(&url), &batch)
.header("Accept", "application/json, text/event-stream")
.json(&batch)
.send()
.await
.expect("batch call failed");
assert!(resp.status().is_success());
let ctype = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_owned();
assert!(
ctype.contains("text/event-stream"),
"a batch with an opted-in inner request must stream, got content-type {ctype:?}"
);
let body = body_within(resp, "batch call").await;
assert!(
body.contains("notifications/message") && body.contains(MARKER),
"batch SSE body must carry the inner request's log, got: {body}"
);
let shared_session = uuid::Uuid::new_v4().to_string();
let call_for = |tool: &str| {
serde_json::json!({
"jsonrpc": "2.0", "id": 9, "method": "tools/call",
"params": {
"name": tool,
"arguments": {},
"_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/logLevel": "info" }
}
})
};
let send = |call: serde_json::Value| {
routed(client.post(&url), &call)
.header("Accept", "application/json, text/event-stream")
.header("Mcp-Session-Id", shared_session.clone())
.json(&call)
.send()
};
let (resp_a, resp_b) = tokio::join!(send(call_for("shout_a")), send(call_for("shout_b")));
let body_a = body_within(resp_a.expect("a failed"), "concurrent A").await;
let body_b = body_within(resp_b.expect("b failed"), "concurrent B").await;
assert!(
body_a.contains(MARKER_A) && !body_a.contains(MARKER_B),
"response A must carry only its own log, got: {body_a}"
);
assert!(
body_b.contains(MARKER_B) && !body_b.contains(MARKER_A),
"response B must carry only its own log, got: {body_b}"
);
handle.abort();
}
fn pick_free_port() -> u16 {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
port
}
fn meta() -> serde_json::Value {
serde_json::json!({
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
})
}
fn routed(req: reqwest::RequestBuilder, body: &serde_json::Value) -> reqwest::RequestBuilder {
let req = req.header("MCP-Protocol-Version", "2026-07-28");
let Some(method) = body["method"].as_str() else {
return req;
};
let req = req.header("Mcp-Method", method);
let name = match method {
"tools/call" | "prompts/get" => body.pointer("/params/name"),
"resources/read" => body.pointer("/params/uri"),
_ => None,
};
match name.and_then(|v| v.as_str()) {
Some(name) => req.header("Mcp-Name", name),
None => req,
}
}