#![cfg(all(not(target_arch = "wasm32"), feature = "schema-generation"))]
#[path = "common/duplex.rs"]
mod duplex;
use std::sync::Arc;
use duplex::{call_via_core, call_via_server};
use pmcp::server::builder::ServerCoreBuilder;
use pmcp::server::core::ProtocolHandler;
use pmcp::server::typed_tool::{TypedTool, TypedToolWithOutput};
use pmcp::types::{CallToolResult, Content};
use pmcp::{Server, ToolHandler};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
fn text_of(result: &CallToolResult) -> &str {
match result
.content
.first()
.expect("result carries at least one content block")
{
Content::Text { text } => text,
other => panic!("expected text content, got {other:?}"),
}
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ProposeArgs {
corpus: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct ProposedSchema {
entities: Vec<String>,
count: u32,
}
fn propose_schema_tool() -> impl ToolHandler {
TypedToolWithOutput::new("propose_schema", |args: ProposeArgs, _extra| {
Box::pin(async move {
let _ = args.corpus;
Ok(ProposedSchema {
entities: vec!["Person".to_string(), "Company".to_string()],
count: 2,
})
})
})
.with_description("Propose a graph schema for a corpus")
}
fn expected_proposed_schema() -> Value {
json!({ "entities": ["Person", "Company"], "count": 2 })
}
fn text_only_tool() -> impl ToolHandler {
TypedTool::new("text_only", |args: ProposeArgs, _extra| {
Box::pin(async move {
let _ = args.corpus;
Ok(json!({ "plain": true }))
})
})
}
#[test]
fn structured_dual_emits_one_value_in_both_voices() {
let value = expected_proposed_schema();
let result = CallToolResult::structured(value.clone());
assert!(!result.is_error, "structured() is a success result");
assert_eq!(
result.structured_content,
Some(value.clone()),
"structuredContent carries the value verbatim"
);
let parsed: Value =
serde_json::from_str(text_of(&result)).expect("text voice is valid JSON of the value");
assert_eq!(parsed, value, "text voice round-trips to the same value");
}
#[test]
fn structured_value_accepts_a_scalar_and_serializes_the_text_voice() {
let result = CallToolResult::structured_value(json!(42));
assert!(!result.is_error, "structured_value() is a success result");
assert_eq!(
result.structured_content,
Some(json!(42)),
"a scalar reaches structuredContent verbatim"
);
assert_eq!(
text_of(&result),
"42",
"the text voice is the canonical serialization of the same scalar"
);
}
#[test]
fn present_null_structured_content_does_not_survive_a_typed_reread() {
let result = CallToolResult::structured_value(json!(null));
assert_eq!(
result.structured_content,
Some(Value::Null),
"the constructed value carries a present null"
);
let wire = serde_json::to_string(&result).expect("result serializes");
assert!(
wire.contains(r#""structuredContent":null"#),
"the wire keeps the present null: {wire}"
);
let reread: CallToolResult = serde_json::from_str(&wire).expect("result deserializes");
assert_eq!(
reread.structured_content, None,
"FINDING: serde maps a JSON null onto Option::None, so the present-null is lost on a \
typed re-read even though the wire carried it"
);
}
#[test]
fn structured_keeps_its_object_shaped_intent() {
let value = json!({ "a": 1 });
let result = CallToolResult::structured(value.clone());
assert!(!result.is_error);
assert_eq!(result.structured_content, Some(value.clone()));
let parsed: Value = serde_json::from_str(text_of(&result)).expect("text voice is JSON");
assert_eq!(parsed, value, "text voice round-trips to the same object");
}
#[test]
fn structured_with_text_separates_the_two_voices() {
let value = expected_proposed_schema();
let result = CallToolResult::structured_with_text(value.clone(), "Proposed 2 entity types.");
assert!(!result.is_error);
assert_eq!(result.structured_content, Some(value));
assert_eq!(
text_of(&result),
"Proposed 2 entity types.",
"human voice differs from the raw serialization"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_auto_emits_structured_content_for_declared_output_schema() {
let server = Server::builder()
.name("structured-output-server")
.version("1.0.0")
.tool("propose_schema", propose_schema_tool())
.build()
.expect("server builds");
let result = call_via_server(server, "propose_schema", json!({ "corpus": "docs" })).await;
assert_eq!(
result.structured_content,
Some(expected_proposed_schema()),
"high-level Server bridges declared outputSchema to structuredContent"
);
let parsed: Value = serde_json::from_str(text_of(&result)).expect("text voice is JSON");
assert_eq!(
parsed,
expected_proposed_schema(),
"text voice still round-trips for text-only clients"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_core_auto_emits_structured_content_for_declared_output_schema() {
let core: Arc<dyn ProtocolHandler> = Arc::new(
ServerCoreBuilder::new()
.name("structured-output-core")
.version("1.0.0")
.tool("propose_schema", propose_schema_tool())
.build()
.expect("core builds"),
);
let result = call_via_core(core, "propose_schema", json!({ "corpus": "docs" })).await;
assert_eq!(
result.structured_content,
Some(expected_proposed_schema()),
"ServerCore bridges declared outputSchema to structuredContent"
);
let parsed: Value = serde_json::from_str(text_of(&result)).expect("text voice is JSON");
assert_eq!(parsed, expected_proposed_schema());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_keeps_text_only_envelope_without_output_schema() {
let server = Server::builder()
.name("text-only-server")
.version("1.0.0")
.tool("text_only", text_only_tool())
.build()
.expect("server builds");
let result = call_via_server(server, "text_only", json!({ "corpus": "docs" })).await;
assert_eq!(
result.structured_content, None,
"no declared outputSchema: high-level Server emits text only"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_core_keeps_text_only_envelope_without_output_schema() {
let core: Arc<dyn ProtocolHandler> = Arc::new(
ServerCoreBuilder::new()
.name("text-only-core")
.version("1.0.0")
.tool("text_only", text_only_tool())
.build()
.expect("core builds"),
);
let result = call_via_core(core, "text_only", json!({ "corpus": "docs" })).await;
assert_eq!(
result.structured_content, None,
"no declared outputSchema: ServerCore emits text only"
);
}
#[cfg(feature = "testing")]
mod era_aware {
use super::duplex::{
assert_no_v2_witness, assert_v2_witness, call_tool_request, call_tool_result_of,
initialize_via_core, raw_via_core, raw_via_server, result_object, v2_accept_list,
};
use super::{json, Arc, ProtocolHandler, Server, ServerCoreBuilder, ToolHandler, Value};
use pmcp::server::typed_tool::TypedToolWithOutput;
use pmcp::types::protocol::Era;
const TOOL: &str = "non_object_output";
fn value_tool(name: &str, output_schema: Value, value: Value) -> impl ToolHandler {
TypedToolWithOutput::new_with_schemas(
name.to_string(),
json!({ "type": "object" }),
Some(output_schema),
move |_args: Value, _extra| {
let value = value.clone();
Box::pin(async move { Ok(value) })
},
)
}
fn scalar_int_tool() -> impl ToolHandler {
value_tool(TOOL, json!({ "type": "integer" }), json!(42))
}
fn array_tool() -> impl ToolHandler {
value_tool(
TOOL,
json!({ "type": "array", "items": { "type": "string" } }),
json!(["a", "b"]),
)
}
fn null_tool() -> impl ToolHandler {
value_tool(TOOL, json!({ "type": "null" }), json!(null))
}
fn mismatched_object_schema_tool() -> impl ToolHandler {
value_tool(
TOOL,
json!({
"type": "object",
"properties": { "n": { "type": "integer" } },
"required": ["n"],
}),
json!(42),
)
}
fn no_args() -> Value {
json!({})
}
fn v2_server(tool: impl ToolHandler + 'static) -> Server {
Server::builder()
.name("structured-output-v2-server")
.version("1.0.0")
.tool(TOOL, tool)
.with_supported_protocol_versions(v2_accept_list())
.build()
.expect("v2 server builds")
}
fn v1_server(tool: impl ToolHandler + 'static) -> Server {
Server::builder()
.name("structured-output-v1-server")
.version("1.0.0")
.tool(TOOL, tool)
.build()
.expect("v1 server builds")
}
fn v2_core(tool: impl ToolHandler + 'static) -> Arc<dyn ProtocolHandler> {
Arc::new(
ServerCoreBuilder::new()
.name("structured-output-v2-core")
.version("1.0.0")
.tool(TOOL, tool)
.with_supported_protocol_versions(v2_accept_list())
.build()
.expect("v2 core builds"),
)
}
fn v1_core(tool: impl ToolHandler + 'static) -> Arc<dyn ProtocolHandler> {
Arc::new(
ServerCoreBuilder::new()
.name("structured-output-v1-core")
.version("1.0.0")
.tool(TOOL, tool)
.build()
.expect("v1 core builds"),
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_v2_scalar_structured_content_survives_round_trip() {
let response = raw_via_server(
v2_server(scalar_int_tool()),
call_tool_request(TOOL, no_args(), Era::V2),
)
.await;
assert_v2_witness(&response, "Server / v2 scalar structuredContent");
assert_eq!(
call_tool_result_of(&response).structured_content,
Some(json!(42)),
"the high-level Server hands a scalar through to structuredContent"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_core_v2_scalar_structured_content_survives_round_trip() {
let response = raw_via_core(
v2_core(scalar_int_tool()),
call_tool_request(TOOL, no_args(), Era::V2),
)
.await;
assert_v2_witness(&response, "ServerCore / v2 scalar structuredContent");
assert_eq!(
call_tool_result_of(&response).structured_content,
Some(json!(42)),
"ServerCore hands a scalar through to structuredContent"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_v2_array_structured_content_survives_round_trip() {
let response = raw_via_server(
v2_server(array_tool()),
call_tool_request(TOOL, no_args(), Era::V2),
)
.await;
assert_v2_witness(&response, "Server / v2 array structuredContent");
assert_eq!(
call_tool_result_of(&response).structured_content,
Some(json!(["a", "b"])),
"the high-level Server hands an array through to structuredContent"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_core_v2_array_structured_content_survives_round_trip() {
let response = raw_via_core(
v2_core(array_tool()),
call_tool_request(TOOL, no_args(), Era::V2),
)
.await;
assert_v2_witness(&response, "ServerCore / v2 array structuredContent");
assert_eq!(
call_tool_result_of(&response).structured_content,
Some(json!(["a", "b"])),
"ServerCore hands an array through to structuredContent"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_v2_null_structured_content_is_present_not_omitted() {
let response = raw_via_server(
v2_server(null_tool()),
call_tool_request(TOOL, no_args(), Era::V2),
)
.await;
assert_v2_witness(&response, "Server / v2 null structuredContent");
assert_eq!(
result_object(&response).get("structuredContent"),
Some(&Value::Null),
"a null payload is PRESENT as an explicit null, NOT an omitted key"
);
let wire = serde_json::to_string(&response).expect("response serializes");
assert!(
wire.contains(r#""structuredContent":null"#),
"the key must reach the wire with an explicit null, not be skipped: {wire}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_core_v2_null_structured_content_is_present_not_omitted() {
let response = raw_via_core(
v2_core(null_tool()),
call_tool_request(TOOL, no_args(), Era::V2),
)
.await;
assert_v2_witness(&response, "ServerCore / v2 null structuredContent");
assert_eq!(
result_object(&response).get("structuredContent"),
Some(&Value::Null),
"a null payload is PRESENT as an explicit null, NOT an omitted key"
);
let wire = serde_json::to_string(&response).expect("response serializes");
assert!(
wire.contains(r#""structuredContent":null"#),
"the key must reach the wire with an explicit null, not be skipped: {wire}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_v2_object_schema_with_scalar_payload_still_returns_a_result() {
let response = raw_via_server(
v2_server(mismatched_object_schema_tool()),
call_tool_request(TOOL, no_args(), Era::V2),
)
.await;
assert_v2_witness(&response, "Server / v2 schema mismatch");
let result = call_tool_result_of(&response);
assert!(
!result.is_error,
"a schema mismatch is warn-only, not an error result"
);
assert_eq!(
result.structured_content,
Some(json!(42)),
"the value still reaches the wire verbatim"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_core_v2_object_schema_with_scalar_payload_still_returns_a_result() {
let response = raw_via_core(
v2_core(mismatched_object_schema_tool()),
call_tool_request(TOOL, no_args(), Era::V2),
)
.await;
assert_v2_witness(&response, "ServerCore / v2 schema mismatch");
let result = call_tool_result_of(&response);
assert!(
!result.is_error,
"a schema mismatch is warn-only, not an error result"
);
assert_eq!(
result.structured_content,
Some(json!(42)),
"the value still reaches the wire verbatim"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_v1_scalar_structured_content_is_unchanged() {
let response = raw_via_server(
v1_server(scalar_int_tool()),
call_tool_request(TOOL, no_args(), Era::V1),
)
.await;
assert_no_v2_witness(&response, "Server / v1 scalar structuredContent");
assert_eq!(
call_tool_result_of(&response).structured_content,
Some(json!(42)),
"pmcp ALREADY emits a scalar on v1 — more permissive than v1's spec text, and D-05 \
FREEZES that rather than correcting it"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn server_core_v1_scalar_structured_content_is_unchanged() {
let core = v1_core(scalar_int_tool());
initialize_via_core(&core).await;
let response = raw_via_core(core, call_tool_request(TOOL, no_args(), Era::V1)).await;
assert_no_v2_witness(&response, "ServerCore / v1 scalar structuredContent");
assert_eq!(
call_tool_result_of(&response).structured_content,
Some(json!(42)),
"the v1 ServerCore path is unchanged, scalar included"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn structured_output_the_v2_witness_is_load_bearing() {
let opted_in = v2_core(scalar_int_tool());
let response = raw_via_core(opted_in, call_tool_request(TOOL, no_args(), Era::V2)).await;
assert_v2_witness(&response, "opted-in core, Era::V2 request");
let not_opted_in = v1_core(scalar_int_tool());
initialize_via_core(¬_opted_in).await;
let response =
raw_via_core(not_opted_in, call_tool_request(TOOL, no_args(), Era::V2)).await;
assert_no_v2_witness(&response, "non-opted-in core, identical Era::V2 request");
assert_eq!(
call_tool_result_of(&response).structured_content,
Some(json!(42)),
"the payload still round-trips — only the ERA differs between the two halves"
);
}
}