#![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_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"
);
}