#![cfg(all(
not(feature = "legacy-spec"),
feature = "http-server-volga",
feature = "http-client"
))]
use neva::App;
#[tokio::test(flavor = "multi_thread")]
async fn mirrored_headers_must_describe_the_call() {
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")));
app.map_tool("query", |_region: String| async move { "ok".to_string() })
.with_input_schema(|_| {
serde_json::json!({
"type": "object",
"properties": {
"region": { "type": "string", "x-mcp-header": "Region" }
}
})
.into()
});
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": "query",
"arguments": { "region": "us-west1" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
});
let post = |extra: Vec<(&'static str, String)>| {
let client = client.clone();
let url = url.clone();
let call = call.clone();
async move {
let mut req = client
.post(&url)
.header("MCP-Protocol-Version", "2026-07-28")
.header("Mcp-Method", "tools/call")
.header("Mcp-Name", "query");
for (name, value) in extra {
req = req.header(name, value);
}
req.json(&call).send().await.expect("send")
}
};
let resp = post(vec![("Mcp-Param-Region", "us-west1".into())]).await;
assert!(resp.status().is_success(), "got {}", resp.status());
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(
body.pointer("/result/content/0/text")
.and_then(|v| v.as_str()),
Some("ok"),
"got: {body}"
);
let resp = post(vec![("Mcp-Param-Region", "us-east1".into())]).await;
assert_eq!(resp.status(), reqwest::StatusCode::BAD_REQUEST);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["error"]["code"], -32020, "got: {body}");
let resp = post(vec![]).await;
assert_eq!(resp.status(), reqwest::StatusCode::BAD_REQUEST);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["error"]["code"], -32020, "got: {body}");
let resp = post(vec![
("Mcp-Param-Region", "us-west1".into()),
("Mcp-Param-Tenant", "acme".into()),
])
.await;
assert!(resp.status().is_success(), "got {}", resp.status());
handle.abort();
}
#[tokio::test(flavor = "multi_thread")]
async fn a_header_without_the_argument_it_mirrors_is_rejected() {
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")));
app.map_tool("query", || async move { "ok".to_string() })
.with_input_schema(|_| {
serde_json::json!({
"type": "object",
"properties": {
"region": { "type": "string", "x-mcp-header": "Region" }
}
})
.into()
});
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": "query",
"arguments": {},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
});
let resp = client
.post(&url)
.header("MCP-Protocol-Version", "2026-07-28")
.header("Mcp-Method", "tools/call")
.header("Mcp-Name", "query")
.header("Mcp-Param-Region", "us-west1")
.json(&call)
.send()
.await
.expect("send");
assert_eq!(resp.status(), reqwest::StatusCode::BAD_REQUEST);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["error"]["code"], -32020, "got: {body}");
handle.abort();
}
#[tokio::test(flavor = "multi_thread")]
async fn a_batched_call_of_an_annotated_tool_still_runs() {
use neva::client::Client;
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")));
app.map_tool("query", |region: String| async move { region })
.with_input_schema(|_| {
serde_json::json!({
"type": "object",
"properties": {
"region": { "type": "string", "x-mcp-header": "Region" }
}
})
.into()
});
let handle = tokio::spawn(async move { app.run().await });
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
match tokio::net::TcpStream::connect(&addr).await {
Ok(_) => break,
Err(_) if tokio::time::Instant::now() < deadline => {
tokio::time::sleep(std::time::Duration::from_millis(50)).await
}
Err(err) => panic!("server never became reachable: {err}"),
}
}
let mut client = Client::new().with_options(|opt| {
opt.with_http(|http| http.bind(&addr).with_endpoint("/mcp"))
.with_timeout(std::time::Duration::from_secs(5))
});
client.connect().await.expect("connect");
let tools = client.list_tools(None).await.expect("tools/list");
assert_eq!(tools.tools.len(), 1, "the annotated tool must survive");
let responses = client
.batch()
.call_tool("query", [("region", "us-west1")])
.send()
.await
.expect("batch send");
assert_eq!(responses.len(), 1);
let result = responses
.into_iter()
.next()
.expect("one response")
.into_result::<serde_json::Value>()
.expect("the batched call must not be rejected for missing headers");
assert_eq!(
result.pointer("/content/0/text").and_then(|v| v.as_str()),
Some("us-west1"),
"got: {result}"
);
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
}