#![cfg(not(target_arch = "wasm32"))]
#[path = "common/duplex.rs"]
mod duplex;
#[path = "common/host_pump.rs"]
mod host_pump;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use proptest::prelude::*;
use serde_json::{json, Value};
use pmcp::client::host::{HostElicitationHandler, HostSamplingHandler};
use pmcp::types::elicitation::{ElicitAction, ElicitRequestParams, ElicitResult};
use pmcp::types::jsonrpc::{JSONRPCResponse, ResponsePayload};
use pmcp::types::protocol::{ClientRequest, Request, ServerRequest};
use pmcp::types::roots::{ListRootsResult, Root};
use pmcp::types::sampling::{
CreateMessageParams, CreateMessageResult, SamplingMessage, SamplingMessageContent, ToolChoice,
};
use pmcp::types::tools::ToolInfo;
use pmcp::types::{ClientCapabilities, Content, RequestId, Role};
use pmcp::{ClientBuilder, Result};
struct CapturingSampling {
observed: Arc<Mutex<Option<CreateMessageParams>>>,
model: String,
}
#[async_trait]
impl HostSamplingHandler for CapturingSampling {
async fn handle_create_message(
&self,
params: CreateMessageParams,
) -> Result<CreateMessageResult> {
*self.observed.lock().unwrap() = Some(params);
Ok(CreateMessageResult::new(
Content::text("host completion"),
self.model.clone(),
))
}
}
struct MockElicit;
#[async_trait]
impl HostElicitationHandler for MockElicit {
async fn handle_elicitation(&self, _params: ElicitRequestParams) -> Result<ElicitResult> {
let mut content = std::collections::HashMap::new();
content.insert("answer".to_string(), json!("yes"));
Ok(ElicitResult {
action: ElicitAction::Accept,
content: Some(content),
})
}
}
async fn pump_one_inbound(
mut server_t: duplex::DuplexTransport,
inbound_id: RequestId,
inbound: Request,
) -> JSONRPCResponse {
host_pump::inject_and_capture(&mut server_t, inbound_id, inbound).await
}
fn result_payload(response: &JSONRPCResponse) -> Value {
match &response.payload {
ResponsePayload::Result(v) => v.clone(),
ResponsePayload::Error(e) => panic!("expected a success result, got error: {e:?}"),
}
}
#[tokio::test]
async fn sampling_answered_by_host_handler() {
let observed = Arc::new(Mutex::new(None));
let (client_t, server_t) = duplex::DuplexTransport::pair();
let sent = CreateMessageParams::new(vec![SamplingMessage::new(
Role::User,
SamplingMessageContent::Text {
text: "summarize this".to_string(),
meta: None,
},
)])
.with_tools(vec![ToolInfo::new(
"search",
Some("search tool".to_string()),
json!({"type": "object"}),
)])
.with_tool_choice(ToolChoice::auto());
let inbound_id = RequestId::from("sample-1".to_string());
let inbound = Request::Client(Box::new(ClientRequest::CreateMessage(Box::new(
sent.clone(),
))));
let server_task = tokio::spawn(pump_one_inbound(server_t, inbound_id.clone(), inbound));
let mut client = ClientBuilder::new(client_t)
.on_sampling(CapturingSampling {
observed: observed.clone(),
model: "host-model".to_string(),
})
.build();
client
.initialize(ClientCapabilities::default())
.await
.expect("initialize");
let _ = client.list_tools(None).await.expect("list_tools succeeds");
let response = server_task.await.expect("server task");
assert_eq!(response.id, inbound_id);
let result: CreateMessageResult =
serde_json::from_value(result_payload(&response)).expect("CreateMessageResult");
assert_eq!(result.model, "host-model");
let obs = observed.lock().unwrap().clone().expect("handler ran");
assert_eq!(
serde_json::to_value(&sent).unwrap(),
serde_json::to_value(&obs).unwrap(),
"params must pass through unchanged"
);
}
#[tokio::test]
async fn roots_answered_by_host_provider() {
let (client_t, server_t) = duplex::DuplexTransport::pair();
let inbound_id = RequestId::from("roots-1".to_string());
let inbound = Request::Server(Box::new(ServerRequest::ListRoots));
let server_task = tokio::spawn(pump_one_inbound(server_t, inbound_id.clone(), inbound));
let mut client = ClientBuilder::new(client_t)
.on_roots(|| async {
Ok(ListRootsResult {
roots: vec![Root {
uri: "file:///workspace".to_string(),
name: Some("workspace".to_string()),
}],
})
})
.build();
client
.initialize(ClientCapabilities::default())
.await
.expect("initialize");
let _ = client.list_tools(None).await.expect("list_tools succeeds");
let response = server_task.await.expect("server task");
assert_eq!(response.id, inbound_id);
let result: ListRootsResult =
serde_json::from_value(result_payload(&response)).expect("ListRootsResult");
assert_eq!(result.roots.len(), 1);
assert_eq!(result.roots[0].uri, "file:///workspace");
}
#[tokio::test]
async fn elicitation_answered_via_raw_pump() {
let (client_t, server_t) = duplex::DuplexTransport::pair();
let inbound_id = RequestId::from("elicit-1".to_string());
let params = ElicitRequestParams::Form {
message: "confirm?".to_string(),
requested_schema: json!({"type": "object"}),
};
let inbound = Request::Server(Box::new(ServerRequest::ElicitationCreate(Box::new(params))));
let server_task = tokio::spawn(pump_one_inbound(server_t, inbound_id.clone(), inbound));
let mut client = ClientBuilder::new(client_t)
.on_elicitation(MockElicit)
.build();
client
.initialize(ClientCapabilities::default())
.await
.expect("initialize");
let _ = client.list_tools(None).await.expect("list_tools succeeds");
let response = server_task.await.expect("server task");
assert_eq!(response.id, inbound_id);
let result: ElicitResult =
serde_json::from_value(result_payload(&response)).expect("ElicitResult");
assert_eq!(result.action, ElicitAction::Accept);
assert_eq!(
result.content.and_then(|c| c.get("answer").cloned()),
Some(json!("yes")),
"elicitation answer must come from the registered host handler"
);
}
#[tokio::test]
async fn inbound_ping_answered_with_empty_result() {
let (client_t, server_t) = duplex::DuplexTransport::pair();
let inbound_id = RequestId::from("ping-1".to_string());
let inbound = Request::Client(Box::new(ClientRequest::Ping));
let server_task = tokio::spawn(pump_one_inbound(server_t, inbound_id.clone(), inbound));
let mut client = ClientBuilder::new(client_t).build();
client
.initialize(ClientCapabilities::default())
.await
.expect("initialize");
let _ = client
.list_tools(None)
.await
.expect("list_tools succeeds after inbound ping");
let response = server_task.await.expect("server task");
assert_eq!(response.id, inbound_id);
assert!(
response.is_success(),
"inbound ping must be answered with a success result, got: {response:?}"
);
assert_eq!(
result_payload(&response),
json!({}),
"ping response must be an empty object per the MCP spec"
);
}
fn arb_params() -> impl Strategy<Value = CreateMessageParams> {
(
proptest::option::of("[a-z ]{0,16}"),
proptest::option::of(0.0f64..1.0f64),
proptest::option::of(1u32..4096u32),
any::<bool>(),
prop_oneof![
Just(None),
Just(Some(ToolChoice::auto())),
Just(Some(ToolChoice::required())),
Just(Some(ToolChoice::none())),
],
"[a-z ]{1,24}",
any::<bool>(),
)
.prop_map(
|(system_prompt, temperature, max_tokens, has_tools, tool_choice, text, tool_msgs)| {
let mut messages = vec![SamplingMessage::new(
Role::User,
SamplingMessageContent::Text { text, meta: None },
)];
if tool_msgs {
messages.push(SamplingMessage::new(
Role::Assistant,
SamplingMessageContent::ToolUse {
name: "search".to_string(),
id: "call-1".to_string(),
input: json!({"q": "rust"}),
meta: None,
},
));
messages.push(SamplingMessage::new(
Role::User,
SamplingMessageContent::ToolResult {
tool_use_id: "call-1".to_string(),
content: vec![Content::text("found")],
structured_content: None,
is_error: None,
meta: None,
},
));
}
let mut params = CreateMessageParams::new(messages);
params.system_prompt = system_prompt;
params.temperature = temperature;
params.max_tokens = max_tokens;
if has_tools {
params = params.with_tools(vec![ToolInfo::new(
"search",
Some("search".to_string()),
json!({"type": "object"}),
)]);
}
if let Some(tc) = tool_choice {
params = params.with_tool_choice(tc);
}
params
},
)
}
async fn observe_sampling(params: CreateMessageParams) -> CreateMessageParams {
let observed = Arc::new(Mutex::new(None));
let (client_t, server_t) = duplex::DuplexTransport::pair();
let inbound = Request::Client(Box::new(ClientRequest::CreateMessage(Box::new(params))));
let server_task = tokio::spawn(pump_one_inbound(
server_t,
RequestId::from("s".to_string()),
inbound,
));
let mut client = ClientBuilder::new(client_t)
.on_sampling(CapturingSampling {
observed: observed.clone(),
model: "m".to_string(),
})
.build();
client
.initialize(ClientCapabilities::default())
.await
.expect("initialize");
let _ = client.list_tools(None).await.expect("list_tools");
let _ = server_task.await.expect("server task");
let obs = observed.lock().unwrap().clone();
obs.expect("handler ran")
}
proptest! {
#![proptest_config(ProptestConfig { cases: 24, ..ProptestConfig::default() })]
#[test]
fn prop_sampling_passthrough(params in arb_params()) {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let observed = rt.block_on(observe_sampling(params.clone()));
prop_assert_eq!(
serde_json::to_value(¶ms).unwrap(),
serde_json::to_value(&observed).unwrap()
);
}
}