rust-sdk/crates/rmcp/tests/test_prompt_macros.rs
Teddy Tennant 3075dc9152
fix(model): deprecate ServerInfo and ClientInfo aliases (#1156)
* fix(model): deprecate ServerInfo and ClientInfo aliases

Those names collide with the protocol's serverInfo/clientInfo fields,
which are only Implementation identity. Prefer InitializeResult and
InitializeRequestParams. Handler trait signatures, the tool_handler
macro output, tests, examples and docs now use the canonical types; the
aliases remain as deprecated shims for the rest of 3.x.

Fixes #1082

* fix(model): keep public signatures on the deprecated aliases

The public API check compares rendered signatures, and rustdoc records a
type alias by the name it was written with. Respelling
`ServerHandler::get_info`, `ClientHandler::get_info`,
`DiscoverResult::from_server_info` and `impl ClientHandler for ClientInfo`
onto `InitializeResult`/`InitializeRequestParams` therefore reads as
changed public items even though the aliases resolve to those exact types
and nothing downstream breaks.

Put those four signatures back on the aliases so the published API is
byte-identical, and note why at the alias definitions. The deprecation
still steers new code, and the macro output, tests, examples and docs
keep using the canonical names.

* fix(model): migrate leftover ClientInfo test sites

Two tests landed on main after the alias deprecation and still named
ClientInfo. Clippy -D warnings and the no-local feature test job both
failed on them.
2026-09-12 12:11:30 -04:00

374 lines
11 KiB
Rust

#![cfg(not(feature = "local"))]
//cargo test --test test_prompt_macros --features "client server"
#![allow(dead_code)]
use std::sync::Arc;
use rmcp::{
ClientHandler, ServerHandler, ServiceExt,
handler::server::{router::prompt::PromptRouter, wrapper::Parameters},
model::{
ContentBlock, GetPromptRequestParams, GetPromptResult, InitializeRequestParams,
PromptMessage, Role,
},
prompt, prompt_handler, prompt_router,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, JsonSchema)]
struct CodeReviewRequest {
file_path: String,
language: String,
}
#[prompt_handler(router = self.prompt_router)]
impl ServerHandler for Server {}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Server {
prompt_router: PromptRouter<Self>,
}
impl Default for Server {
fn default() -> Self {
Self::new()
}
}
#[prompt_router]
impl Server {
pub fn new() -> Self {
Self {
prompt_router: Self::prompt_router(),
}
}
/// This prompt is used to review code for best practices.
#[prompt(
name = "code-review",
description = "Review code for best practices and issues."
)]
pub async fn code_review(&self, params: Parameters<CodeReviewRequest>) -> Vec<PromptMessage> {
vec![
PromptMessage::new_text(
Role::User,
format!(
"Please review the {} code in: {}",
params.0.language, params.0.file_path
),
),
PromptMessage::new_text(
Role::Assistant,
"I'll review this code for best practices and potential issues.".to_string(),
),
]
}
#[prompt]
async fn empty_param(&self) -> Vec<PromptMessage> {
vec![PromptMessage::new_text(
Role::Assistant,
"This is a prompt with no parameters.".to_string(),
)]
}
}
// define generic service trait
pub trait DataService: Send + Sync + 'static {
fn get_context(&self) -> String;
}
// mock service for test
#[derive(Clone)]
struct MockDataService;
impl DataService for MockDataService {
fn get_context(&self) -> String {
"mock context data".to_string()
}
}
// define generic server
#[derive(Debug, Clone)]
pub struct GenericServer<DS: DataService> {
data_service: Arc<DS>,
prompt_router: PromptRouter<Self>,
}
#[prompt_router]
impl<DS: DataService> GenericServer<DS> {
pub fn new(data_service: DS) -> Self {
Self {
data_service: Arc::new(data_service),
prompt_router: Self::prompt_router(),
}
}
#[prompt(description = "Get contextual help from the service")]
async fn get_help(&self) -> GetPromptResult {
let context = self.data_service.get_context();
GetPromptResult::new(vec![
PromptMessage::new_text(
Role::User,
"I need help with the current context.".to_string(),
),
PromptMessage::new_text(
Role::Assistant,
format!(
"Based on the context '{}', here's how I can help...",
context
),
),
])
.with_description("Contextual help based on service data")
}
}
#[prompt_handler]
impl<DS: DataService> ServerHandler for GenericServer<DS> {}
#[tokio::test]
async fn test_prompt_macros() {
let server = Server::new();
let _attr = Server::code_review_prompt_attr();
let _code_review_prompt_attr_fn = Server::code_review_prompt_attr;
let _code_review_fn = Server::code_review;
let result = server
.code_review(Parameters(CodeReviewRequest {
file_path: "/src/main.rs".into(),
language: "rust".into(),
}))
.await;
assert_eq!(result.len(), 2);
assert_eq!(result[0].role, Role::User);
assert_eq!(result[1].role, Role::Assistant);
}
#[tokio::test]
async fn test_prompt_macros_with_empty_param() {
let _attr = Server::empty_param_prompt_attr();
println!("{_attr:?}");
assert!(
_attr.arguments.is_none(),
"Empty param prompt should have no arguments"
);
}
#[tokio::test]
async fn test_prompt_macros_with_generics() {
let mock_service = MockDataService;
let server = GenericServer::new(mock_service);
let _attr = GenericServer::<MockDataService>::get_help_prompt_attr();
let _get_help_call_fn = GenericServer::<MockDataService>::get_help;
let _get_help_fn = GenericServer::<MockDataService>::get_help;
let result = server.get_help().await;
assert!(result.description.is_some());
assert_eq!(result.messages.len(), 2);
match &result.messages[1].content {
ContentBlock::Text(text_content) => {
assert!(text_content.text.contains("mock context data"));
}
_ => panic!("Expected text content"),
}
}
#[tokio::test]
async fn test_prompt_macros_with_optional_param() {
let _attr = Server::code_review_prompt_attr();
let arguments = _attr.arguments.as_ref().unwrap();
// Check that we have the expected number of arguments
assert_eq!(arguments.len(), 2);
// Verify file_path is required
let file_path_arg = arguments.iter().find(|a| a.name == "file_path").unwrap();
assert_eq!(file_path_arg.required, Some(true));
// Verify language is required
let language_arg = arguments.iter().find(|a| a.name == "language").unwrap();
assert_eq!(language_arg.required, Some(true));
}
impl CodeReviewRequest {}
// Struct defined for testing optional field schema generation
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
struct OptionalFieldTestSchema {
#[schemars(description = "An optional description field")]
description: Option<String>,
}
// Struct defined for testing optional i64 field schema generation and null handling
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
struct OptionalI64TestSchema {
#[schemars(description = "An optional i64 field")]
count: Option<i64>,
mandatory_field: String, // Added to ensure non-empty object schema
}
// Dummy struct to host the test prompt method
#[derive(Debug, Clone)]
pub struct OptionalSchemaTester {
prompt_router: PromptRouter<Self>,
}
impl Default for OptionalSchemaTester {
fn default() -> Self {
Self::new()
}
}
impl OptionalSchemaTester {
pub fn new() -> Self {
Self {
prompt_router: Self::prompt_router(),
}
}
}
#[prompt_router]
impl OptionalSchemaTester {
// Dummy prompt function using the test schema as an aggregated parameter
#[prompt(description = "A prompt to test optional schema generation")]
async fn test_optional(&self, _req: Parameters<OptionalFieldTestSchema>) -> Vec<PromptMessage> {
vec![PromptMessage::new_text(
Role::Assistant,
"Testing optional fields".to_string(),
)]
}
// Prompt function to test optional i64 handling
#[prompt(description = "A prompt to test optional i64 schema generation")]
async fn test_optional_i64(
&self,
Parameters(req): Parameters<OptionalI64TestSchema>,
) -> GetPromptResult {
let message = match req.count {
Some(c) => format!("Received count: {}", c),
None => "Received null count".to_string(),
};
GetPromptResult::new(vec![PromptMessage::new_text(Role::Assistant, message)])
.with_description("Test result for optional i64")
}
}
#[prompt_handler]
// Implement ServerHandler to route prompt calls for OptionalSchemaTester
impl ServerHandler for OptionalSchemaTester {}
#[test]
fn test_optional_field_schema_generation_via_macro() {
// tests https://github.com/modelcontextprotocol/rust-sdk/issues/135
// Get the attributes generated by the #[prompt] macro helper
let prompt_attr = OptionalSchemaTester::test_optional_prompt_attr();
// Print the actual generated schema for debugging
println!(
"Actual arguments generated by macro: {:#?}",
prompt_attr.arguments
);
// Verify the schema generated for the aggregated OptionalFieldTestSchema
let arguments = prompt_attr.arguments.expect("Should have arguments");
// Check that we have an argument for the optional description field
let description_arg = arguments
.iter()
.find(|arg| arg.name == "description")
.expect("Should have description argument");
// Assert that optional fields are marked as not required
assert_eq!(
description_arg.required,
Some(false),
"Optional fields should be marked as not required"
);
// Check the description is correct
assert_eq!(
description_arg.description.as_deref(),
Some("An optional description field")
);
}
// Define a dummy client handler
#[derive(Debug, Clone, Default)]
struct DummyClientHandler {}
impl ClientHandler for DummyClientHandler {
fn get_info(&self) -> InitializeRequestParams {
InitializeRequestParams::default()
}
}
#[tokio::test]
async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> {
let (server_transport, client_transport) = tokio::io::duplex(4096);
// Server setup
let server = OptionalSchemaTester::new();
let server_handle = tokio::spawn(async move {
server.serve(server_transport).await?.waiting().await?;
anyhow::Ok(())
});
// Create a simple client handler that just forwards prompt calls
let client_handler = DummyClientHandler::default();
let client = client_handler.serve(client_transport).await?;
// Test null case
let result = client
.get_prompt(
GetPromptRequestParams::new("test_optional_i64").with_arguments(
serde_json::json!({
"count": null,
"mandatory_field": "test_null"
})
.as_object()
.unwrap()
.clone(),
),
)
.await?;
let result_text = match &result.messages.first().unwrap().content {
ContentBlock::Text(text_content) => text_content.text.as_str(),
_ => panic!("Expected text content"),
};
assert_eq!(
result_text, "Received null count",
"Null case should return expected message"
);
// Test Some case
let some_result = client
.get_prompt(
GetPromptRequestParams::new("test_optional_i64").with_arguments(
serde_json::json!({
"count": 42,
"mandatory_field": "test_some"
})
.as_object()
.unwrap()
.clone(),
),
)
.await?;
let some_result_text = match &some_result.messages.first().unwrap().content {
ContentBlock::Text(text_content) => text_content.text.as_str(),
_ => panic!("Expected text content"),
};
assert_eq!(
some_result_text, "Received count: 42",
"Some case should return expected message"
);
client.cancel().await?;
server_handle.await??;
Ok(())
}