feat: Add prompt support (#351)
* feat: add prompt support with typed argument handling - Implement #[prompt], #[prompt_router], and #[prompt_handler] macros - Add automatic JSON schema generation from Rust types for arguments - Support flexible async handler signatures with automatic adaptation - Create PromptRouter for efficient prompt dispatch - Include comprehensive tests and example implementation This enables MCP servers to provide reusable prompt templates that LLMs can discover and invoke with strongly-typed parameters, similar to the existing tool system but optimized for prompt use cases. 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * refactor: unify parameter handling between tools and prompts - Replace Arguments<T> with Parameters<T> for consistent API - Create shared common module for tool/prompt utilities - Modernize async handling with futures::future::BoxFuture - Move cached_schema_for_type to common module for reuse - Update error types from rmcp::Error to rmcp::ErrorData - Add comprehensive trait implementations for parameter extraction 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * refactor: extract Parameters wrapper to shared module and unify trait usage - Move Parameters type from common.rs to dedicated wrapper/parameters.rs module - Unify extractor traits by using FromContextPart for both tools and prompts - Remove duplicate FromToolCallContextPart and FromPromptContextPart traits - Add structured output support to Json wrapper with IntoCallToolResult impl - Improve error messages with more descriptive panic for schema serialization - Update all imports across codebase to use new module path - Clean up trailing whitespace and formatting inconsistencies This consolidates parameter extraction logic and reduces code duplication between tool and prompt handlers while maintaining backward compatibility. * chore: remove committed .egg-info directory and update gitignore * docs: fix documentation formatting
This commit is contained in:
parent
c0452f93a4
commit
9349f5cb26
11 changed files with 3191 additions and 464 deletions
|
|
@ -1,7 +1,7 @@
|
|||
#![allow(dead_code)]
|
||||
use rmcp::{
|
||||
ServerHandler,
|
||||
handler::server::{router::tool::ToolRouter, tool::Parameters},
|
||||
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
||||
model::{ServerCapabilities, ServerInfo},
|
||||
schemars, tool, tool_router,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use rmcp::{
|
||||
ErrorData as McpError, handler::server::tool::Parameters, model::*, schemars, tool, tool_router,
|
||||
ErrorData as McpError, handler::server::wrapper::Parameters, model::*, schemars, tool,
|
||||
tool_router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
|
|||
|
|
@ -37,23 +37,26 @@ impl ClientHandler for MyClient {
|
|||
}
|
||||
}
|
||||
|
||||
impl Default for MyServer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MyServer {
|
||||
tool_router: ToolRouter<Self>,
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl MyServer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MyServer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl MyServer {
|
||||
#[tool]
|
||||
pub async fn some_progress(
|
||||
meta: Meta,
|
||||
|
|
|
|||
161
crates/rmcp/tests/test_prompt_handler.rs
Normal file
161
crates/rmcp/tests/test_prompt_handler.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
//cargo test --test test_prompt_handler --features "client server"
|
||||
// Tests for verifying that the #[prompt_handler] macro correctly generates
|
||||
// the ServerHandler trait implementation methods.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use rmcp::{
|
||||
RoleServer, ServerHandler,
|
||||
handler::server::router::prompt::PromptRouter,
|
||||
model::{GetPromptRequestParam, GetPromptResult, ListPromptsResult, PaginatedRequestParam},
|
||||
prompt_handler,
|
||||
service::RequestContext,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TestPromptServer {
|
||||
prompt_router: PromptRouter<Self>,
|
||||
}
|
||||
|
||||
impl Default for TestPromptServer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TestPromptServer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
prompt_router: PromptRouter::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[prompt_handler]
|
||||
impl ServerHandler for TestPromptServer {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CustomRouterServer {
|
||||
custom_router: PromptRouter<Self>,
|
||||
}
|
||||
|
||||
impl Default for CustomRouterServer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CustomRouterServer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
custom_router: PromptRouter::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_custom_router(&self) -> &PromptRouter<Self> {
|
||||
&self.custom_router
|
||||
}
|
||||
}
|
||||
|
||||
#[prompt_handler(router = self.custom_router)]
|
||||
impl ServerHandler for CustomRouterServer {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GenericPromptServer<T: Send + Sync + 'static> {
|
||||
prompt_router: PromptRouter<Self>,
|
||||
_marker: std::marker::PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<T: Send + Sync + 'static> Default for GenericPromptServer<T> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Send + Sync + 'static> GenericPromptServer<T> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
prompt_router: PromptRouter::new(),
|
||||
_marker: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[prompt_handler]
|
||||
impl<T: Send + Sync + 'static> ServerHandler for GenericPromptServer<T> {}
|
||||
|
||||
#[test]
|
||||
fn test_prompt_handler_basic() {
|
||||
let server = TestPromptServer::new();
|
||||
|
||||
// Test that the server implements ServerHandler
|
||||
fn assert_server_handler<T: ServerHandler>(_: &T) {}
|
||||
assert_server_handler(&server);
|
||||
|
||||
// Test that the prompt router is accessible
|
||||
assert_eq!(server.prompt_router.list_all().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prompt_handler_custom_router() {
|
||||
let server = CustomRouterServer::new();
|
||||
|
||||
// Test that the server implements ServerHandler
|
||||
fn assert_server_handler<T: ServerHandler>(_: &T) {}
|
||||
assert_server_handler(&server);
|
||||
|
||||
// Test that the custom router is used
|
||||
assert_eq!(server.custom_router.list_all().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prompt_handler_with_generics() {
|
||||
let server = GenericPromptServer::<String>::new();
|
||||
|
||||
// Test that generic server implements ServerHandler
|
||||
fn assert_server_handler<T: ServerHandler>(_: &T) {}
|
||||
assert_server_handler(&server);
|
||||
|
||||
// Test with a different generic type
|
||||
let server2 = GenericPromptServer::<i32>::new();
|
||||
assert_server_handler(&server2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prompt_handler_trait_implementation() {
|
||||
// This test verifies that the prompt_handler macro generates proper ServerHandler implementation
|
||||
// The actual method signatures are tested through the ServerHandler trait bound
|
||||
fn compile_time_check<T: ServerHandler>() {}
|
||||
|
||||
compile_time_check::<TestPromptServer>();
|
||||
compile_time_check::<CustomRouterServer>();
|
||||
compile_time_check::<GenericPromptServer<String>>();
|
||||
}
|
||||
|
||||
// Test that the macro works with different server configurations
|
||||
mod nested {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NestedServer {
|
||||
prompt_router: PromptRouter<Self>,
|
||||
}
|
||||
|
||||
impl NestedServer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
prompt_router: PromptRouter::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[prompt_handler]
|
||||
impl ServerHandler for NestedServer {}
|
||||
|
||||
#[test]
|
||||
fn test_nested_prompt_handler() {
|
||||
let server = NestedServer::new();
|
||||
// Verify it implements ServerHandler
|
||||
fn assert_server_handler<T: ServerHandler>(_: &T) {}
|
||||
assert_server_handler(&server);
|
||||
}
|
||||
}
|
||||
291
crates/rmcp/tests/test_prompt_macro_annotations.rs
Normal file
291
crates/rmcp/tests/test_prompt_macro_annotations.rs
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
//cargo test --test test_prompt_macro_annotations --features "client server"
|
||||
#![allow(dead_code)]
|
||||
|
||||
use rmcp::{
|
||||
ServerHandler,
|
||||
handler::server::wrapper::Parameters,
|
||||
model::{GetPromptResult, Prompt, PromptMessage, PromptMessageRole},
|
||||
prompt,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TestServer;
|
||||
|
||||
impl ServerHandler for TestServer {}
|
||||
|
||||
#[derive(Serialize, Deserialize, JsonSchema)]
|
||||
struct TestArgs {
|
||||
/// The input text to process
|
||||
input: String,
|
||||
/// Optional configuration
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
config: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, JsonSchema)]
|
||||
struct ComplexArgs {
|
||||
/// Required field
|
||||
required_field: String,
|
||||
/// Optional string field
|
||||
#[schemars(description = "An optional string parameter")]
|
||||
optional_string: Option<String>,
|
||||
/// Optional number field
|
||||
optional_number: Option<i64>,
|
||||
/// Array field
|
||||
items: Vec<String>,
|
||||
}
|
||||
|
||||
// Test basic prompt attribute generation
|
||||
#[prompt]
|
||||
async fn basic_prompt(_server: &TestServer) -> Vec<PromptMessage> {
|
||||
vec![PromptMessage::new_text(
|
||||
PromptMessageRole::Assistant,
|
||||
"Basic response",
|
||||
)]
|
||||
}
|
||||
|
||||
// Test prompt with custom name
|
||||
#[prompt(name = "custom_name")]
|
||||
async fn named_prompt(_server: &TestServer) -> Vec<PromptMessage> {
|
||||
vec![PromptMessage::new_text(
|
||||
PromptMessageRole::Assistant,
|
||||
"Named response",
|
||||
)]
|
||||
}
|
||||
|
||||
// Test prompt with custom description
|
||||
#[prompt(description = "This is a custom description")]
|
||||
async fn described_prompt(_server: &TestServer) -> Vec<PromptMessage> {
|
||||
vec![PromptMessage::new_text(
|
||||
PromptMessageRole::Assistant,
|
||||
"Described response",
|
||||
)]
|
||||
}
|
||||
|
||||
// Test prompt with both name and description
|
||||
#[prompt(name = "full_custom", description = "Fully customized prompt")]
|
||||
async fn fully_custom_prompt(_server: &TestServer) -> Vec<PromptMessage> {
|
||||
vec![PromptMessage::new_text(
|
||||
PromptMessageRole::Assistant,
|
||||
"Fully custom response",
|
||||
)]
|
||||
}
|
||||
|
||||
// Test prompt with doc comments
|
||||
/// This is a doc comment description
|
||||
/// that spans multiple lines
|
||||
#[prompt]
|
||||
async fn doc_comment_prompt(_server: &TestServer) -> Vec<PromptMessage> {
|
||||
vec![PromptMessage::new_text(
|
||||
PromptMessageRole::Assistant,
|
||||
"Doc comment response",
|
||||
)]
|
||||
}
|
||||
|
||||
// Test prompt with doc comments and explicit description (explicit wins)
|
||||
/// This is a doc comment
|
||||
#[prompt(description = "This overrides the doc comment")]
|
||||
async fn override_doc_prompt(_server: &TestServer) -> Vec<PromptMessage> {
|
||||
vec![PromptMessage::new_text(
|
||||
PromptMessageRole::Assistant,
|
||||
"Override response",
|
||||
)]
|
||||
}
|
||||
|
||||
// Test prompt with arguments
|
||||
#[prompt]
|
||||
async fn args_prompt(_server: &TestServer, _args: Parameters<TestArgs>) -> Vec<PromptMessage> {
|
||||
vec![PromptMessage::new_text(
|
||||
PromptMessageRole::Assistant,
|
||||
"Args response",
|
||||
)]
|
||||
}
|
||||
|
||||
// Test prompt with complex arguments
|
||||
#[prompt]
|
||||
async fn complex_args_prompt(
|
||||
_server: &TestServer,
|
||||
_args: Parameters<ComplexArgs>,
|
||||
) -> GetPromptResult {
|
||||
GetPromptResult {
|
||||
description: Some("Complex args result".to_string()),
|
||||
messages: vec![PromptMessage::new_text(
|
||||
PromptMessageRole::Assistant,
|
||||
"Complex response",
|
||||
)],
|
||||
}
|
||||
}
|
||||
|
||||
// Test sync prompt
|
||||
#[prompt]
|
||||
fn sync_prompt(_server: &TestServer) -> Vec<PromptMessage> {
|
||||
vec![PromptMessage::new_text(
|
||||
PromptMessageRole::Assistant,
|
||||
"Sync response",
|
||||
)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_prompt_attr() {
|
||||
let attr = basic_prompt_prompt_attr();
|
||||
assert_eq!(attr.name, "basic_prompt");
|
||||
assert_eq!(attr.description, None);
|
||||
assert!(attr.arguments.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_named_prompt_attr() {
|
||||
let attr = named_prompt_prompt_attr();
|
||||
assert_eq!(attr.name, "custom_name");
|
||||
assert_eq!(attr.description, None);
|
||||
assert!(attr.arguments.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_described_prompt_attr() {
|
||||
let attr = described_prompt_prompt_attr();
|
||||
assert_eq!(attr.name, "described_prompt");
|
||||
assert_eq!(
|
||||
attr.description.as_deref(),
|
||||
Some("This is a custom description")
|
||||
);
|
||||
assert!(attr.arguments.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fully_custom_prompt_attr() {
|
||||
let attr = fully_custom_prompt_prompt_attr();
|
||||
assert_eq!(attr.name, "full_custom");
|
||||
assert_eq!(attr.description.as_deref(), Some("Fully customized prompt"));
|
||||
assert!(attr.arguments.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_doc_comment_prompt_attr() {
|
||||
let attr = doc_comment_prompt_prompt_attr();
|
||||
assert_eq!(attr.name, "doc_comment_prompt");
|
||||
assert!(attr.description.is_some());
|
||||
let desc = attr.description.unwrap();
|
||||
assert!(desc.contains("This is a doc comment description"));
|
||||
assert!(desc.contains("that spans multiple lines"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_override_doc_prompt_attr() {
|
||||
let attr = override_doc_prompt_prompt_attr();
|
||||
assert_eq!(attr.name, "override_doc_prompt");
|
||||
assert_eq!(
|
||||
attr.description.as_deref(),
|
||||
Some("This overrides the doc comment")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_args_prompt_attr() {
|
||||
let attr = args_prompt_prompt_attr();
|
||||
assert_eq!(attr.name, "args_prompt");
|
||||
|
||||
let args = attr.arguments.as_ref().unwrap();
|
||||
assert_eq!(args.len(), 2);
|
||||
|
||||
// Check input field
|
||||
let input_arg = args.iter().find(|a| a.name == "input").unwrap();
|
||||
assert_eq!(input_arg.required, Some(true));
|
||||
assert_eq!(
|
||||
input_arg.description.as_deref(),
|
||||
Some("The input text to process")
|
||||
);
|
||||
|
||||
// Check config field
|
||||
let config_arg = args.iter().find(|a| a.name == "config").unwrap();
|
||||
assert_eq!(config_arg.required, Some(false));
|
||||
assert_eq!(
|
||||
config_arg.description.as_deref(),
|
||||
Some("Optional configuration")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_args_prompt_attr() {
|
||||
let attr = complex_args_prompt_prompt_attr();
|
||||
assert_eq!(attr.name, "complex_args_prompt");
|
||||
|
||||
let args = attr.arguments.as_ref().unwrap();
|
||||
assert_eq!(args.len(), 4);
|
||||
|
||||
// Check required_field
|
||||
let required_arg = args.iter().find(|a| a.name == "required_field").unwrap();
|
||||
assert_eq!(required_arg.required, Some(true));
|
||||
assert_eq!(required_arg.description.as_deref(), Some("Required field"));
|
||||
|
||||
// Check optional_string
|
||||
let optional_string_arg = args.iter().find(|a| a.name == "optional_string").unwrap();
|
||||
assert_eq!(optional_string_arg.required, Some(false));
|
||||
assert_eq!(
|
||||
optional_string_arg.description.as_deref(),
|
||||
Some("An optional string parameter")
|
||||
);
|
||||
|
||||
// Check optional_number
|
||||
let optional_number_arg = args.iter().find(|a| a.name == "optional_number").unwrap();
|
||||
assert_eq!(optional_number_arg.required, Some(false));
|
||||
assert_eq!(
|
||||
optional_number_arg.description.as_deref(),
|
||||
Some("Optional number field")
|
||||
);
|
||||
|
||||
// Check items
|
||||
let items_arg = args.iter().find(|a| a.name == "items").unwrap();
|
||||
assert_eq!(items_arg.required, Some(true));
|
||||
assert_eq!(items_arg.description.as_deref(), Some("Array field"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_prompt_attr() {
|
||||
let attr = sync_prompt_prompt_attr();
|
||||
assert_eq!(attr.name, "sync_prompt");
|
||||
assert!(attr.arguments.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prompt_attr_function_type() {
|
||||
// Test that the generated function returns the correct type
|
||||
fn assert_prompt_attr_fn(_: impl Fn() -> Prompt) {}
|
||||
|
||||
assert_prompt_attr_fn(basic_prompt_prompt_attr);
|
||||
assert_prompt_attr_fn(named_prompt_prompt_attr);
|
||||
assert_prompt_attr_fn(described_prompt_prompt_attr);
|
||||
assert_prompt_attr_fn(fully_custom_prompt_prompt_attr);
|
||||
assert_prompt_attr_fn(doc_comment_prompt_prompt_attr);
|
||||
assert_prompt_attr_fn(override_doc_prompt_prompt_attr);
|
||||
assert_prompt_attr_fn(args_prompt_prompt_attr);
|
||||
assert_prompt_attr_fn(complex_args_prompt_prompt_attr);
|
||||
assert_prompt_attr_fn(sync_prompt_prompt_attr);
|
||||
}
|
||||
|
||||
// Test generic prompts
|
||||
#[derive(Debug, Clone)]
|
||||
struct GenericServer<T: Send + Sync + 'static> {
|
||||
_marker: std::marker::PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<T: Send + Sync + 'static> ServerHandler for GenericServer<T> {}
|
||||
|
||||
#[prompt]
|
||||
async fn generic_prompt<T: Send + Sync + 'static>(
|
||||
_server: &GenericServer<T>,
|
||||
) -> Vec<PromptMessage> {
|
||||
vec![PromptMessage::new_text(
|
||||
PromptMessageRole::Assistant,
|
||||
"Generic response",
|
||||
)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generic_prompt_attr() {
|
||||
let attr = generic_prompt_prompt_attr();
|
||||
assert_eq!(attr.name, "generic_prompt");
|
||||
assert!(attr.arguments.is_none());
|
||||
}
|
||||
383
crates/rmcp/tests/test_prompt_macros.rs
Normal file
383
crates/rmcp/tests/test_prompt_macros.rs
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
//cargo test --test test_prompt_macros --features "client server"
|
||||
#![allow(dead_code)]
|
||||
use std::sync::Arc;
|
||||
|
||||
use rmcp::{
|
||||
ClientHandler, RoleServer, ServerHandler, ServiceExt,
|
||||
handler::server::{router::prompt::PromptRouter, wrapper::Parameters},
|
||||
model::{
|
||||
ClientInfo, GetPromptRequestParam, GetPromptResult, ListPromptsResult,
|
||||
PaginatedRequestParam, PromptMessage, PromptMessageRole,
|
||||
},
|
||||
prompt, prompt_handler, prompt_router,
|
||||
service::RequestContext,
|
||||
};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, JsonSchema)]
|
||||
pub struct CodeReviewRequest {
|
||||
pub file_path: String,
|
||||
pub 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(
|
||||
PromptMessageRole::User,
|
||||
format!(
|
||||
"Please review the {} code in: {}",
|
||||
params.0.language, params.0.file_path
|
||||
),
|
||||
),
|
||||
PromptMessage::new_text(
|
||||
PromptMessageRole::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(
|
||||
PromptMessageRole::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 {
|
||||
description: Some("Contextual help based on service data".to_string()),
|
||||
messages: vec![
|
||||
PromptMessage::new_text(
|
||||
PromptMessageRole::User,
|
||||
"I need help with the current context.".to_string(),
|
||||
),
|
||||
PromptMessage::new_text(
|
||||
PromptMessageRole::Assistant,
|
||||
format!(
|
||||
"Based on the context '{}', here's how I can help...",
|
||||
context
|
||||
),
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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, PromptMessageRole::User);
|
||||
assert_eq!(result[1].role, PromptMessageRole::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 {
|
||||
rmcp::model::PromptMessageContent::Text { text } => {
|
||||
assert!(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)]
|
||||
pub struct OptionalFieldTestSchema {
|
||||
#[schemars(description = "An optional description field")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
// Struct defined for testing optional i64 field schema generation and null handling
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct OptionalI64TestSchema {
|
||||
#[schemars(description = "An optional i64 field")]
|
||||
pub count: Option<i64>,
|
||||
pub 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(
|
||||
PromptMessageRole::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 {
|
||||
description: Some("Test result for optional i64".to_string()),
|
||||
messages: vec![PromptMessage::new_text(
|
||||
PromptMessageRole::Assistant,
|
||||
message,
|
||||
)],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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) -> ClientInfo {
|
||||
ClientInfo::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(GetPromptRequestParam {
|
||||
name: "test_optional_i64".into(),
|
||||
arguments: Some(
|
||||
serde_json::json!({
|
||||
"count": null,
|
||||
"mandatory_field": "test_null"
|
||||
})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let result_text = match &result.messages.first().unwrap().content {
|
||||
rmcp::model::PromptMessageContent::Text { text } => 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(GetPromptRequestParam {
|
||||
name: "test_optional_i64".into(),
|
||||
arguments: Some(
|
||||
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 {
|
||||
rmcp::model::PromptMessageContent::Text { text } => 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(())
|
||||
}
|
||||
105
crates/rmcp/tests/test_prompt_routers.rs
Normal file
105
crates/rmcp/tests/test_prompt_routers.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use rmcp::{
|
||||
ServerHandler,
|
||||
handler::server::wrapper::Parameters,
|
||||
model::{GetPromptResult, PromptMessage, PromptMessageRole},
|
||||
};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TestHandler<T: 'static = ()> {
|
||||
pub _marker: std::marker::PhantomData<fn(*const T)>,
|
||||
}
|
||||
|
||||
impl<T: 'static> ServerHandler for TestHandler<T> {}
|
||||
|
||||
#[derive(Debug, schemars::JsonSchema, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Request {
|
||||
pub fields: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, schemars::JsonSchema, serde::Deserialize, serde::Serialize)]
|
||||
pub struct Sum {
|
||||
pub a: i32,
|
||||
pub b: i32,
|
||||
}
|
||||
|
||||
#[rmcp::prompt_router(router = "test_router")]
|
||||
impl<T> TestHandler<T> {
|
||||
#[rmcp::prompt]
|
||||
async fn async_method(
|
||||
&self,
|
||||
Parameters(Request { fields }): Parameters<Request>,
|
||||
) -> Vec<PromptMessage> {
|
||||
drop(fields);
|
||||
vec![PromptMessage::new_text(
|
||||
PromptMessageRole::Assistant,
|
||||
"Async method response",
|
||||
)]
|
||||
}
|
||||
|
||||
#[rmcp::prompt]
|
||||
fn sync_method(
|
||||
&self,
|
||||
Parameters(Request { fields }): Parameters<Request>,
|
||||
) -> Vec<PromptMessage> {
|
||||
drop(fields);
|
||||
vec![PromptMessage::new_text(
|
||||
PromptMessageRole::Assistant,
|
||||
"Sync method response",
|
||||
)]
|
||||
}
|
||||
}
|
||||
|
||||
#[rmcp::prompt]
|
||||
async fn async_function(Parameters(Request { fields }): Parameters<Request>) -> Vec<PromptMessage> {
|
||||
drop(fields);
|
||||
vec![PromptMessage::new_text(
|
||||
PromptMessageRole::Assistant,
|
||||
"Async function response",
|
||||
)]
|
||||
}
|
||||
|
||||
#[rmcp::prompt]
|
||||
fn async_function2<T>(_callee: &TestHandler<T>) -> BoxFuture<'_, GetPromptResult> {
|
||||
Box::pin(async move {
|
||||
GetPromptResult {
|
||||
description: Some("Async function 2".to_string()),
|
||||
messages: vec![PromptMessage::new_text(
|
||||
PromptMessageRole::Assistant,
|
||||
"Async function 2 response",
|
||||
)],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prompt_router() {
|
||||
let test_prompt_router = TestHandler::<()>::test_router()
|
||||
.with_route(rmcp::handler::server::router::prompt::PromptRoute::new_dyn(
|
||||
async_function_prompt_attr(),
|
||||
|mut context| {
|
||||
Box::pin(async move {
|
||||
use rmcp::handler::server::{
|
||||
common::FromContextPart, prompt::IntoGetPromptResult,
|
||||
};
|
||||
let params = Parameters::<Request>::from_context_part(&mut context)?;
|
||||
let result = async_function(params).await;
|
||||
result.into_get_prompt_result()
|
||||
})
|
||||
},
|
||||
))
|
||||
.with_route(rmcp::handler::server::router::prompt::PromptRoute::new_dyn(
|
||||
async_function2_prompt_attr(),
|
||||
|context| {
|
||||
Box::pin(async move {
|
||||
use rmcp::handler::server::prompt::IntoGetPromptResult;
|
||||
let result = async_function2(context.server).await;
|
||||
result.into_get_prompt_result()
|
||||
})
|
||||
},
|
||||
));
|
||||
let prompts = test_prompt_router.list_all();
|
||||
assert_eq!(prompts.len(), 4);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
//cargo test --test test_structured_output --features "client server macros"
|
||||
use rmcp::{
|
||||
Json, ServerHandler,
|
||||
handler::server::{router::tool::ToolRouter, tool::Parameters},
|
||||
handler::server::{router::tool::ToolRouter, tool::IntoCallToolResult, wrapper::Parameters},
|
||||
model::{CallToolResult, Content, Tool},
|
||||
tool, tool_handler, tool_router,
|
||||
};
|
||||
|
|
@ -266,12 +266,12 @@ async fn test_output_schema_requires_structured_content() {
|
|||
assert!(calculate_tool.output_schema.is_some());
|
||||
|
||||
// Directly call the tool and verify its result structure
|
||||
let params = rmcp::handler::server::tool::Parameters(CalculationRequest { a: 5, b: 3 });
|
||||
let params = Parameters(CalculationRequest { a: 5, b: 3 });
|
||||
let result = server.calculate(params).await.unwrap();
|
||||
|
||||
// Convert the Json<CalculationResult> to CallToolResult
|
||||
let call_result: Result<CallToolResult, rmcp::ErrorData> =
|
||||
rmcp::handler::server::tool::IntoCallToolResult::into_call_tool_result(result);
|
||||
IntoCallToolResult::into_call_tool_result(result);
|
||||
|
||||
assert!(call_result.is_ok());
|
||||
let call_result = call_result.unwrap();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::sync::Arc;
|
|||
|
||||
use rmcp::{
|
||||
ClientHandler, ServerHandler, ServiceExt,
|
||||
handler::server::{router::tool::ToolRouter, tool::Parameters},
|
||||
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
||||
model::{CallToolRequestParam, ClientInfo},
|
||||
tool, tool_handler, tool_router,
|
||||
};
|
||||
|
|
@ -26,6 +26,14 @@ pub struct Server {
|
|||
tool_router: ToolRouter<Self>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Server {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
|
|
@ -34,12 +42,6 @@ impl Default for Server {
|
|||
|
||||
#[tool_router(router = tool_router)]
|
||||
impl Server {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
/// This tool is used to get the weather of a city.
|
||||
#[tool(name = "get-weather", description = "Get the weather of a city.")]
|
||||
pub async fn get_weather(&self, city: Parameters<GetWeatherRequest>) -> String {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@ use std::collections::HashMap;
|
|||
use futures::future::BoxFuture;
|
||||
use rmcp::{
|
||||
ServerHandler,
|
||||
handler::server::{
|
||||
router::tool::ToolRouter,
|
||||
tool::{CallToolHandler, Parameters},
|
||||
},
|
||||
handler::server::{router::tool::ToolRouter, tool::CallToolHandler, wrapper::Parameters},
|
||||
};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
|
|
|
|||
23
crates/rmcp/tests/test_with_python/.gitignore
vendored
23
crates/rmcp/tests/test_with_python/.gitignore
vendored
|
|
@ -1 +1,22 @@
|
|||
*.lock
|
||||
# Lock files
|
||||
*.lock
|
||||
|
||||
# Python build artifacts
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
.venv/
|
||||
.env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
|
|
|||
Loading…
Reference in a new issue