#![cfg(all(
not(feature = "legacy-spec"),
feature = "server-macros",
feature = "http-server-volga",
feature = "http-client"
))]
use neva::App;
use neva::types::Json;
use serde::{Deserialize, Serialize};
#[derive(Deserialize, schemars::JsonSchema)]
#[allow(dead_code)]
struct Profile {
name: String,
age: u32,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct Opaque {
blob: String,
}
#[derive(Serialize, schemars::JsonSchema)]
struct Greeting {
message: String,
}
#[neva::tool]
async fn add(a: i32, b: i32) -> i32 {
a + b
}
#[neva::tool]
async fn save_profile(profile: Json<Profile>) -> String {
profile.0.name
}
#[neva::tool]
async fn store(payload: Json<Opaque>) -> String {
payload.0.blob
}
#[neva::tool(
input_schema = r#"{"type":"object","properties":{"q":{"type":"string"}},"required":["q"]}"#
)]
async fn search(q: String) -> String {
q
}
#[neva::tool]
async fn describe(name: String, age: i32) -> String {
format!("{name} is {age}")
}
#[neva::tool]
async fn nickname(name: String, alias: Option<String>) -> String {
alias.unwrap_or(name)
}
#[neva::tool]
async fn maybe_profile(profile: Option<Json<Profile>>) -> String {
profile.map(|p| p.0.name).unwrap_or_default()
}
type Progress = neva::types::Meta<neva::types::ProgressToken>;
type Postcode = String;
type MaybeFloor = Option<i32>;
#[neva::tool]
async fn aliased(token: Progress, city: Postcode, floor: MaybeFloor) -> String {
let _ = token;
format!("{city} {floor:?}")
}
#[neva::tool]
async fn spelled(
token: neva::types::Meta<neva::types::ProgressToken>,
city: String,
floor: Option<i32>,
) -> String {
let _ = token;
format!("{city} {floor:?}")
}
#[neva::tool]
async fn make_greeting(name: String) -> Json<Greeting> {
Json(Greeting {
message: format!("hi {name}"),
})
}
#[tokio::test(flavor = "multi_thread")]
async fn tool_macro_emits_json_schema_2020() {
let port = pick_free_port();
let addr = format!("127.0.0.1:{port}");
let app =
App::new().with_options(|opt| opt.with_http(|http| http.bind(&addr).with_endpoint("/mcp")));
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 list_body = serde_json::json!({
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": { "_meta": meta() }
});
let resp = routed(client.post(&url), &list_body)
.json(&list_body)
.send()
.await
.expect("tools/list failed");
assert!(resp.status().is_success());
let body: serde_json::Value = resp.json().await.unwrap();
let tools = body
.pointer("/result/tools")
.and_then(|v| v.as_array())
.expect("missing tools array");
let by_name = |name: &str| -> serde_json::Value {
tools
.iter()
.find(|t| t["name"] == serde_json::json!(name))
.unwrap_or_else(|| panic!("tool {name} not listed"))
.clone()
};
let add = by_name("add");
assert_eq!(add["inputSchema"]["type"], serde_json::json!("object"));
assert_eq!(
add["inputSchema"]["properties"]["a"]["type"],
serde_json::json!("number")
);
assert_eq!(
add["inputSchema"]["properties"]["b"]["type"],
serde_json::json!("number")
);
let req: Vec<String> = serde_json::from_value(add["inputSchema"]["required"].clone()).unwrap();
assert!(req.contains(&"a".to_string()) && req.contains(&"b".to_string()));
let save = by_name("save_profile");
let profile_schema = &save["inputSchema"]["properties"]["profile"];
assert_eq!(profile_schema["type"], serde_json::json!("object"));
assert!(profile_schema["properties"]["name"].is_object());
assert!(profile_schema["properties"]["age"].is_object());
let save_str = serde_json::to_string(&save["inputSchema"]).unwrap();
assert!(!save_str.contains("$ref"), "must be inlined: {save_str}");
assert!(!save_str.contains("$defs"), "must be inlined: {save_str}");
let store = by_name("store");
assert_eq!(
store["inputSchema"]["properties"]["payload"],
serde_json::json!({ "type": "object" })
);
let search = by_name("search");
assert_eq!(
search["inputSchema"]["properties"]["q"]["type"],
serde_json::json!("string")
);
let req: Vec<String> =
serde_json::from_value(search["inputSchema"]["required"].clone()).unwrap();
assert_eq!(req, vec!["q".to_string()]);
assert!(
by_name("save_profile")["outputSchema"].is_null(),
"primitive return must not emit outputSchema"
);
let greet = by_name("make_greeting");
assert_eq!(greet["outputSchema"]["type"], serde_json::json!("object"));
assert!(greet["outputSchema"]["properties"]["message"].is_object());
let nickname = by_name("nickname");
assert_eq!(
nickname["inputSchema"]["properties"]["alias"]["type"],
serde_json::json!("string")
);
let req: Vec<String> =
serde_json::from_value(nickname["inputSchema"]["required"].clone()).unwrap();
assert_eq!(
req,
vec!["name".to_string()],
"`alias` must not be required"
);
let maybe = by_name("maybe_profile");
let profile_schema = &maybe["inputSchema"]["properties"]["profile"];
assert_eq!(profile_schema["type"], serde_json::json!("object"));
assert!(
profile_schema["properties"]["name"].is_object(),
"an Option<Json<T>> arg must still describe T: {profile_schema}"
);
assert!(
maybe["inputSchema"]["required"].is_null(),
"an all-optional tool requires nothing"
);
let aliased = by_name("aliased");
let props = aliased["inputSchema"]["properties"].as_object().unwrap();
assert_eq!(
props.keys().collect::<Vec<_>>(),
vec!["city", "floor"],
"an aliased `Meta<_>` must not be published: {aliased}"
);
assert_eq!(props["city"]["type"], serde_json::json!("string"));
assert_eq!(props["floor"]["type"], serde_json::json!("number"));
let req: Vec<String> =
serde_json::from_value(aliased["inputSchema"]["required"].clone()).unwrap();
assert_eq!(req, vec!["city".to_string()]);
assert_eq!(
aliased["inputSchema"],
by_name("spelled")["inputSchema"],
"an alias must publish exactly what the spelled-out type does"
);
for (args, expected) in [
(serde_json::json!({ "name": "John" }), "John"),
(
serde_json::json!({ "name": "John", "alias": "Johnny" }),
"Johnny",
),
] {
let call_body = serde_json::json!({
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": { "name": "nickname", "arguments": args, "_meta": meta() }
});
let resp = routed(client.post(&url), &call_body)
.json(&call_body)
.send()
.await
.expect("tools/call failed");
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(
body.pointer("/result/content/0/text"),
Some(&serde_json::json!(expected)),
"unexpected response: {body}"
);
}
for args in [
serde_json::json!({ "name": "John", "age": 30 }),
serde_json::json!({ "age": 30, "name": "John" }),
] {
let call_body = serde_json::json!({
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": { "name": "describe", "arguments": args, "_meta": meta() }
});
let resp = routed(client.post(&url), &call_body)
.json(&call_body)
.send()
.await
.expect("tools/call failed");
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(
body.pointer("/result/content/0/text"),
Some(&serde_json::json!("John is 30")),
"unexpected response: {body}"
);
}
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 method = body["method"].as_str().unwrap_or_default();
let req = req
.header("MCP-Protocol-Version", "2026-07-28")
.header("Mcp-Method", method);
let name = match method {
"tools/call" | "prompts/get" => body.pointer("/params/name"),
"resources/read" => body.pointer("/params/uri"),
"tasks/get" | "tasks/update" | "tasks/cancel" => body.pointer("/params/taskId"),
_ => None,
};
match name.and_then(|v| v.as_str()) {
Some(name) => req.header("Mcp-Name", name),
None => req,
}
}