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
6 changed files with 3191 additions and 464 deletions
65
crates/rmcp-macros/src/common.rs
Normal file
65
crates/rmcp-macros/src/common.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
//! Common utilities shared between different macro implementations
|
||||
|
||||
use quote::quote;
|
||||
use syn::{Attribute, Expr, FnArg, ImplItemFn, Signature, Type};
|
||||
|
||||
/// Parse a None expression
|
||||
pub fn none_expr() -> syn::Result<Expr> {
|
||||
syn::parse2::<Expr>(quote! { None })
|
||||
}
|
||||
|
||||
/// Extract documentation from doc attributes
|
||||
pub fn extract_doc_line(existing_docs: Option<String>, attr: &Attribute) -> Option<String> {
|
||||
if !attr.path().is_ident("doc") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let syn::Meta::NameValue(name_value) = &attr.meta else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let syn::Expr::Lit(expr_lit) = &name_value.value else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let syn::Lit::Str(lit_str) = &expr_lit.lit else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let content = lit_str.value().trim().to_string();
|
||||
match (existing_docs, content) {
|
||||
(Some(mut existing_docs), content) if !content.is_empty() => {
|
||||
existing_docs.push('\n');
|
||||
existing_docs.push_str(&content);
|
||||
Some(existing_docs)
|
||||
}
|
||||
(Some(existing_docs), _) => Some(existing_docs),
|
||||
(None, content) if !content.is_empty() => Some(content),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find Parameters<T> type in function signature
|
||||
/// Returns the full Parameters<T> type if found
|
||||
pub fn find_parameters_type_in_sig(sig: &Signature) -> Option<Box<Type>> {
|
||||
sig.inputs.iter().find_map(|input| {
|
||||
if let FnArg::Typed(pat_type) = input {
|
||||
if let Type::Path(type_path) = &*pat_type.ty {
|
||||
if type_path
|
||||
.path
|
||||
.segments
|
||||
.last()
|
||||
.is_some_and(|type_name| type_name.ident == "Parameters")
|
||||
{
|
||||
return Some(pat_type.ty.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// Find Parameters<T> type in ImplItemFn
|
||||
pub fn find_parameters_type_impl(fn_item: &ImplItemFn) -> Option<Box<Type>> {
|
||||
find_parameters_type_in_sig(&fn_item.sig)
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
#[allow(unused_imports)]
|
||||
use proc_macro::TokenStream;
|
||||
|
||||
mod common;
|
||||
mod prompt;
|
||||
mod prompt_handler;
|
||||
mod prompt_router;
|
||||
mod tool;
|
||||
mod tool_handler;
|
||||
mod tool_router;
|
||||
|
|
@ -160,3 +164,102 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> TokenStream {
|
|||
.unwrap_or_else(|err| err.to_compile_error())
|
||||
.into()
|
||||
}
|
||||
|
||||
/// # prompt
|
||||
///
|
||||
/// This macro is used to mark a function as a prompt handler.
|
||||
///
|
||||
/// This will generate a function that returns the attribute of this prompt, with type `rmcp::model::Prompt`.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// | field | type | usage |
|
||||
/// | :- | :- | :- |
|
||||
/// | `name` | `String` | The name of the prompt. If not provided, it defaults to the function name. |
|
||||
/// | `description` | `String` | A description of the prompt. The document of this function will be used if not provided. |
|
||||
/// | `arguments` | `Expr` | An expression that evaluates to `Option<Vec<PromptArgument>>` defining the prompt's arguments. If not provided, it will automatically generate arguments from the `Parameters<T>` type found in the function signature. |
|
||||
///
|
||||
/// ## Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// #[prompt(name = "code_review", description = "Reviews code for best practices")]
|
||||
/// pub async fn code_review_prompt(&self, Parameters(args): Parameters<CodeReviewArgs>) -> Result<Vec<PromptMessage>> {
|
||||
/// // Generate prompt messages based on arguments
|
||||
/// }
|
||||
/// ```
|
||||
#[proc_macro_attribute]
|
||||
pub fn prompt(attr: TokenStream, input: TokenStream) -> TokenStream {
|
||||
prompt::prompt(attr.into(), input.into())
|
||||
.unwrap_or_else(|err| err.to_compile_error())
|
||||
.into()
|
||||
}
|
||||
|
||||
/// # prompt_router
|
||||
///
|
||||
/// This macro generates a prompt router based on functions marked with `#[rmcp::prompt]` in an implementation block.
|
||||
///
|
||||
/// It creates a function that returns a `PromptRouter` instance.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// | field | type | usage |
|
||||
/// | :- | :- | :- |
|
||||
/// | `router` | `Ident` | The name of the router function to be generated. Defaults to `prompt_router`. |
|
||||
/// | `vis` | `Visibility` | The visibility of the generated router function. Defaults to empty. |
|
||||
///
|
||||
/// ## Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// #[prompt_router]
|
||||
/// impl MyPromptHandler {
|
||||
/// #[prompt]
|
||||
/// pub async fn greeting_prompt(&self, Parameters(args): Parameters<GreetingArgs>) -> Result<Vec<PromptMessage>, Error> {
|
||||
/// // Generate greeting prompt using args
|
||||
/// }
|
||||
///
|
||||
/// pub fn new() -> Self {
|
||||
/// Self {
|
||||
/// // the default name of prompt router will be `prompt_router`
|
||||
/// prompt_router: Self::prompt_router(),
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[proc_macro_attribute]
|
||||
pub fn prompt_router(attr: TokenStream, input: TokenStream) -> TokenStream {
|
||||
prompt_router::prompt_router(attr.into(), input.into())
|
||||
.unwrap_or_else(|err| err.to_compile_error())
|
||||
.into()
|
||||
}
|
||||
|
||||
/// # prompt_handler
|
||||
///
|
||||
/// This macro generates handler methods for `get_prompt` and `list_prompts` in the implementation block, using an existing `PromptRouter` instance.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// | field | type | usage |
|
||||
/// | :- | :- | :- |
|
||||
/// | `router` | `Expr` | The expression to access the `PromptRouter` instance. Defaults to `self.prompt_router`. |
|
||||
///
|
||||
/// ## Example
|
||||
/// ```rust,ignore
|
||||
/// #[prompt_handler]
|
||||
/// impl ServerHandler for MyPromptHandler {
|
||||
/// // ...implement other handler methods
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// or using a custom router expression:
|
||||
/// ```rust,ignore
|
||||
/// #[prompt_handler(router = self.get_prompt_router())]
|
||||
/// impl ServerHandler for MyPromptHandler {
|
||||
/// // ...implement other handler methods
|
||||
/// }
|
||||
/// ```
|
||||
#[proc_macro_attribute]
|
||||
pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> TokenStream {
|
||||
prompt_handler::prompt_handler(attr.into(), input.into())
|
||||
.unwrap_or_else(|err| err.to_compile_error())
|
||||
.into()
|
||||
}
|
||||
|
|
|
|||
181
crates/rmcp-macros/src/prompt.rs
Normal file
181
crates/rmcp-macros/src/prompt.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
use darling::{FromMeta, ast::NestedMeta};
|
||||
use proc_macro2::TokenStream;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::{Expr, Ident, ImplItemFn, ReturnType};
|
||||
|
||||
use crate::common::{extract_doc_line, none_expr};
|
||||
|
||||
#[derive(FromMeta, Default, Debug)]
|
||||
#[darling(default)]
|
||||
pub struct PromptAttribute {
|
||||
/// The name of the prompt
|
||||
pub name: Option<String>,
|
||||
/// Optional description of what the prompt does
|
||||
pub description: Option<String>,
|
||||
/// Arguments that can be passed to the prompt
|
||||
pub arguments: Option<Expr>,
|
||||
}
|
||||
|
||||
pub struct ResolvedPromptAttribute {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub arguments: Expr,
|
||||
}
|
||||
|
||||
impl ResolvedPromptAttribute {
|
||||
pub fn into_fn(self, fn_ident: Ident) -> syn::Result<ImplItemFn> {
|
||||
let Self {
|
||||
name,
|
||||
description,
|
||||
arguments,
|
||||
} = self;
|
||||
let description = if let Some(description) = description {
|
||||
quote! { Some(#description.into()) }
|
||||
} else {
|
||||
quote! { None }
|
||||
};
|
||||
let tokens = quote! {
|
||||
pub fn #fn_ident() -> rmcp::model::Prompt {
|
||||
rmcp::model::Prompt {
|
||||
name: #name.into(),
|
||||
description: #description,
|
||||
arguments: #arguments,
|
||||
}
|
||||
}
|
||||
};
|
||||
syn::parse2::<ImplItemFn>(tokens)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prompt(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
|
||||
let attribute = if attr.is_empty() {
|
||||
Default::default()
|
||||
} else {
|
||||
let attr_args = NestedMeta::parse_meta_list(attr)?;
|
||||
PromptAttribute::from_list(&attr_args)?
|
||||
};
|
||||
let mut fn_item = syn::parse2::<ImplItemFn>(input.clone())?;
|
||||
let fn_ident = &fn_item.sig.ident;
|
||||
|
||||
let prompt_attr_fn_ident = format_ident!("{}_prompt_attr", fn_ident);
|
||||
|
||||
// Try to find prompt parameters from function parameters
|
||||
let arguments_expr = if let Some(arguments) = attribute.arguments {
|
||||
arguments
|
||||
} else {
|
||||
// Look for a type named Parameters in the function signature
|
||||
let params_ty = crate::common::find_parameters_type_impl(&fn_item);
|
||||
|
||||
if let Some(params_ty) = params_ty {
|
||||
// Generate arguments from the type's schema with caching
|
||||
syn::parse2::<Expr>(quote! {
|
||||
rmcp::handler::server::prompt::cached_arguments_from_schema::<#params_ty>()
|
||||
})?
|
||||
} else {
|
||||
// No arguments
|
||||
none_expr()?
|
||||
}
|
||||
};
|
||||
|
||||
let name = attribute.name.unwrap_or_else(|| fn_ident.to_string());
|
||||
let description = attribute
|
||||
.description
|
||||
.or_else(|| fn_item.attrs.iter().fold(None, extract_doc_line));
|
||||
let arguments = arguments_expr;
|
||||
|
||||
let resolved_prompt_attr = ResolvedPromptAttribute {
|
||||
name: name.clone(),
|
||||
description: description.clone(),
|
||||
arguments: arguments.clone(),
|
||||
};
|
||||
let prompt_attr_fn = resolved_prompt_attr.into_fn(prompt_attr_fn_ident.clone())?;
|
||||
|
||||
// Modify the input function for async support (same as tool macro)
|
||||
if fn_item.sig.asyncness.is_some() {
|
||||
// 1. remove asyncness from sig
|
||||
// 2. make return type: `futures::future::BoxFuture<'_, #ReturnType>`
|
||||
// 3. make body: { Box::pin(async move { #body }) }
|
||||
let new_output = syn::parse2::<ReturnType>({
|
||||
let mut lt = quote! { 'static };
|
||||
if let Some(receiver) = fn_item.sig.receiver() {
|
||||
if let Some((_, receiver_lt)) = receiver.reference.as_ref() {
|
||||
if let Some(receiver_lt) = receiver_lt {
|
||||
lt = quote! { #receiver_lt };
|
||||
} else {
|
||||
lt = quote! { '_ };
|
||||
}
|
||||
}
|
||||
}
|
||||
match &fn_item.sig.output {
|
||||
syn::ReturnType::Default => {
|
||||
quote! { -> futures::future::BoxFuture<#lt, ()> }
|
||||
}
|
||||
syn::ReturnType::Type(_, ty) => {
|
||||
quote! { -> futures::future::BoxFuture<#lt, #ty> }
|
||||
}
|
||||
}
|
||||
})?;
|
||||
let prev_block = &fn_item.block;
|
||||
let new_block = syn::parse2::<syn::Block>(quote! {
|
||||
{ Box::pin(async move #prev_block ) }
|
||||
})?;
|
||||
fn_item.sig.asyncness = None;
|
||||
fn_item.sig.output = new_output;
|
||||
fn_item.block = new_block;
|
||||
}
|
||||
|
||||
Ok(quote! {
|
||||
#prompt_attr_fn
|
||||
#fn_item
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_prompt_macro() -> syn::Result<()> {
|
||||
let attr = quote! {
|
||||
name = "example-prompt",
|
||||
description = "An example prompt"
|
||||
};
|
||||
let input = quote! {
|
||||
async fn example_prompt(&self, Parameters(args): Parameters<ExampleArgs>) -> Result<String> {
|
||||
Ok("Example prompt response".to_string())
|
||||
}
|
||||
};
|
||||
let result = prompt(attr, input)?;
|
||||
|
||||
// Verify the output contains both the attribute function and the modified function
|
||||
let result_str = result.to_string();
|
||||
assert!(result_str.contains("example_prompt_prompt_attr"));
|
||||
assert!(
|
||||
result_str.contains("rmcp")
|
||||
&& result_str.contains("model")
|
||||
&& result_str.contains("Prompt")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_doc_comment_description() -> syn::Result<()> {
|
||||
let attr = quote! {}; // No explicit description
|
||||
let input = quote! {
|
||||
/// This is a test prompt description
|
||||
/// with multiple lines
|
||||
fn test_prompt(&self) -> Result<String> {
|
||||
Ok("Test".to_string())
|
||||
}
|
||||
};
|
||||
let result = prompt(attr, input)?;
|
||||
|
||||
// The output should contain the description from doc comments
|
||||
let result_str = result.to_string();
|
||||
assert!(result_str.contains("This is a test prompt description"));
|
||||
assert!(result_str.contains("with multiple lines"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
141
crates/rmcp-macros/src/prompt_handler.rs
Normal file
141
crates/rmcp-macros/src/prompt_handler.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
use darling::FromMeta;
|
||||
use proc_macro2::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::{Expr, ImplItem, ItemImpl, parse_quote};
|
||||
|
||||
#[derive(FromMeta, Debug, Default)]
|
||||
#[darling(default)]
|
||||
pub struct PromptHandlerAttribute {
|
||||
pub router: Option<Expr>,
|
||||
}
|
||||
|
||||
pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
|
||||
let attribute = if attr.is_empty() {
|
||||
Default::default()
|
||||
} else {
|
||||
let attr_args = darling::ast::NestedMeta::parse_meta_list(attr)?;
|
||||
PromptHandlerAttribute::from_list(&attr_args)?
|
||||
};
|
||||
|
||||
let mut impl_block = syn::parse2::<ItemImpl>(input)?;
|
||||
|
||||
let router_expr = attribute
|
||||
.router
|
||||
.unwrap_or_else(|| syn::parse2(quote! { self.prompt_router }).unwrap());
|
||||
|
||||
// Add get_prompt implementation
|
||||
let get_prompt_impl: ImplItem = parse_quote! {
|
||||
async fn get_prompt(
|
||||
&self,
|
||||
request: GetPromptRequestParam,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> Result<GetPromptResult, rmcp::ErrorData> {
|
||||
let prompt_context = rmcp::handler::server::prompt::PromptContext::new(
|
||||
self,
|
||||
request.name,
|
||||
request.arguments,
|
||||
context,
|
||||
);
|
||||
#router_expr.get_prompt(prompt_context).await
|
||||
}
|
||||
};
|
||||
|
||||
// Add list_prompts implementation
|
||||
let list_prompts_impl: ImplItem = parse_quote! {
|
||||
async fn list_prompts(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParam>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ListPromptsResult, rmcp::ErrorData> {
|
||||
let prompts = #router_expr.list_all();
|
||||
Ok(ListPromptsResult {
|
||||
prompts,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// Check if methods already exist and replace them if they do
|
||||
let mut has_get_prompt = false;
|
||||
let mut has_list_prompts = false;
|
||||
|
||||
for item in &mut impl_block.items {
|
||||
if let ImplItem::Fn(fn_item) = item {
|
||||
match fn_item.sig.ident.to_string().as_str() {
|
||||
"get_prompt" => {
|
||||
*item = get_prompt_impl.clone();
|
||||
has_get_prompt = true;
|
||||
}
|
||||
"list_prompts" => {
|
||||
*item = list_prompts_impl.clone();
|
||||
has_list_prompts = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add methods if they don't exist
|
||||
if !has_get_prompt {
|
||||
impl_block.items.push(get_prompt_impl);
|
||||
}
|
||||
if !has_list_prompts {
|
||||
impl_block.items.push(list_prompts_impl);
|
||||
}
|
||||
|
||||
Ok(quote! {
|
||||
#impl_block
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_prompt_handler_macro() -> syn::Result<()> {
|
||||
let input = quote! {
|
||||
impl ServerHandler for MyPromptHandler {
|
||||
// Other handler methods...
|
||||
}
|
||||
};
|
||||
|
||||
let result = prompt_handler(TokenStream::new(), input)?;
|
||||
let result_str = result.to_string();
|
||||
|
||||
// Check that the required methods were generated
|
||||
assert!(result_str.contains("async fn get_prompt"));
|
||||
assert!(result_str.contains("PromptContext") && result_str.contains("new"));
|
||||
assert!(result_str.contains("async fn list_prompts"));
|
||||
assert!(result_str.contains("ListPromptsResult"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prompt_handler_with_custom_router() -> syn::Result<()> {
|
||||
let attr = quote! { router = self.get_prompt_router() };
|
||||
let input = quote! {
|
||||
impl ServerHandler for MyPromptHandler {
|
||||
// Other handler methods...
|
||||
}
|
||||
};
|
||||
|
||||
let result = prompt_handler(attr, input)?;
|
||||
let result_str = result.to_string();
|
||||
|
||||
// Check that the custom router expression is used
|
||||
assert!(
|
||||
result_str.contains("self")
|
||||
&& result_str.contains("get_prompt_router")
|
||||
&& result_str.contains("get_prompt")
|
||||
);
|
||||
assert!(
|
||||
result_str.contains("self")
|
||||
&& result_str.contains("get_prompt_router")
|
||||
&& result_str.contains("list_all")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
111
crates/rmcp-macros/src/prompt_router.rs
Normal file
111
crates/rmcp-macros/src/prompt_router.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
use darling::FromMeta;
|
||||
use proc_macro2::TokenStream;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::{ImplItem, ItemImpl, Visibility, parse_quote};
|
||||
|
||||
#[derive(FromMeta, Debug, Default)]
|
||||
#[darling(default)]
|
||||
pub struct PromptRouterAttribute {
|
||||
pub router: Option<String>,
|
||||
pub vis: Option<Visibility>,
|
||||
}
|
||||
|
||||
pub fn prompt_router(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
|
||||
let attribute = if attr.is_empty() {
|
||||
Default::default()
|
||||
} else {
|
||||
let attr_args = darling::ast::NestedMeta::parse_meta_list(attr)?;
|
||||
PromptRouterAttribute::from_list(&attr_args)?
|
||||
};
|
||||
|
||||
let mut impl_block = syn::parse2::<ItemImpl>(input)?;
|
||||
let self_ty = &impl_block.self_ty;
|
||||
|
||||
let router_fn_ident = attribute
|
||||
.router
|
||||
.map(|s| format_ident!("{}", s))
|
||||
.unwrap_or_else(|| format_ident!("prompt_router"));
|
||||
let vis = attribute.vis.unwrap_or(Visibility::Inherited);
|
||||
|
||||
let mut prompt_route_fn_calls = Vec::new();
|
||||
|
||||
for item in &mut impl_block.items {
|
||||
if let ImplItem::Fn(fn_item) = item {
|
||||
let has_prompt_attr = fn_item.attrs.iter().any(|attr| {
|
||||
attr.path()
|
||||
.segments
|
||||
.last()
|
||||
.map(|seg| seg.ident == "prompt")
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
if has_prompt_attr {
|
||||
let fn_ident = &fn_item.sig.ident;
|
||||
let attr_fn_ident = format_ident!("{}_prompt_attr", fn_ident);
|
||||
|
||||
// Check what parameters the function takes
|
||||
let mut param_names = Vec::new();
|
||||
let mut param_types = Vec::new();
|
||||
|
||||
for input in &fn_item.sig.inputs {
|
||||
if let syn::FnArg::Typed(pat_type) = input {
|
||||
// Extract parameter pattern and type
|
||||
param_types.push(&*pat_type.ty);
|
||||
param_names.push(&*pat_type.pat);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the exact same pattern as tool_router
|
||||
prompt_route_fn_calls.push(quote! {
|
||||
.with_route((Self::#attr_fn_ident(), Self::#fn_ident))
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let router_fn: ImplItem = parse_quote! {
|
||||
#vis fn #router_fn_ident() -> rmcp::handler::server::router::prompt::PromptRouter<#self_ty> {
|
||||
rmcp::handler::server::router::prompt::PromptRouter::new()
|
||||
#(#prompt_route_fn_calls)*
|
||||
}
|
||||
};
|
||||
|
||||
impl_block.items.push(router_fn);
|
||||
|
||||
Ok(quote! {
|
||||
#impl_block
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_prompt_router_macro() -> syn::Result<()> {
|
||||
let input = quote! {
|
||||
impl MyPromptHandler {
|
||||
#[prompt]
|
||||
async fn greeting_prompt(&self) -> Result<Vec<PromptMessage>, Error> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[prompt]
|
||||
async fn code_review_prompt(&self, Parameters(args): Parameters<CodeReviewArgs>) -> Result<Vec<PromptMessage>, Error> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result = prompt_router(TokenStream::new(), input)?;
|
||||
let result_str = result.to_string();
|
||||
|
||||
// Check that the prompt_router function was generated
|
||||
assert!(result_str.contains("fn prompt_router"));
|
||||
assert!(result_str.contains("PromptRouter :: new"));
|
||||
assert!(result_str.contains("greeting_prompt_prompt_attr"));
|
||||
assert!(result_str.contains("code_review_prompt_prompt_attr"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,65 @@ use darling::{FromMeta, ast::NestedMeta};
|
|||
use proc_macro2::TokenStream;
|
||||
use quote::{ToTokens, format_ident, quote};
|
||||
use syn::{Expr, Ident, ImplItemFn, ReturnType};
|
||||
|
||||
use crate::common::{extract_doc_line, none_expr};
|
||||
|
||||
/// Check if a type is Json<T> and extract the inner type T
|
||||
fn extract_json_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
|
||||
if let syn::Type::Path(type_path) = ty {
|
||||
if let Some(last_segment) = type_path.path.segments.last() {
|
||||
if last_segment.ident == "Json" {
|
||||
if let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments {
|
||||
if let Some(syn::GenericArgument::Type(inner_type)) = args.args.first() {
|
||||
return Some(inner_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract schema expression from a function's return type
|
||||
/// Handles patterns like Json<T> and Result<Json<T>, E>
|
||||
fn extract_schema_from_return_type(ret_type: &syn::Type) -> Option<Expr> {
|
||||
// First, try direct Json<T>
|
||||
if let Some(inner_type) = extract_json_inner_type(ret_type) {
|
||||
return syn::parse2::<Expr>(quote! {
|
||||
rmcp::handler::server::tool::cached_schema_for_type::<#inner_type>()
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
// Then, try Result<Json<T>, E>
|
||||
let type_path = match ret_type {
|
||||
syn::Type::Path(path) => path,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let last_segment = type_path.path.segments.last()?;
|
||||
|
||||
if last_segment.ident != "Result" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let args = match &last_segment.arguments {
|
||||
syn::PathArguments::AngleBracketed(args) => args,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let ok_type = match args.args.first()? {
|
||||
syn::GenericArgument::Type(ty) => ty,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let inner_type = extract_json_inner_type(ok_type)?;
|
||||
|
||||
syn::parse2::<Expr>(quote! {
|
||||
rmcp::handler::server::tool::cached_schema_for_type::<#inner_type>()
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
#[derive(FromMeta, Default, Debug)]
|
||||
#[darling(default)]
|
||||
pub struct ToolAttribute {
|
||||
|
|
@ -95,98 +154,6 @@ pub struct ToolAnnotationsAttribute {
|
|||
pub open_world_hint: Option<bool>,
|
||||
}
|
||||
|
||||
fn none_expr() -> Expr {
|
||||
syn::parse2::<Expr>(quote! { None }).unwrap()
|
||||
}
|
||||
|
||||
/// Check if a type is Json<T> and extract the inner type T
|
||||
fn extract_json_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
|
||||
if let syn::Type::Path(type_path) = ty {
|
||||
if let Some(last_segment) = type_path.path.segments.last() {
|
||||
if last_segment.ident == "Json" {
|
||||
if let syn::PathArguments::AngleBracketed(args) = &last_segment.arguments {
|
||||
if let Some(syn::GenericArgument::Type(inner_type)) = args.args.first() {
|
||||
return Some(inner_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract schema expression from a function's return type
|
||||
/// Handles patterns like Json<T> and Result<Json<T>, E>
|
||||
fn extract_schema_from_return_type(ret_type: &syn::Type) -> Option<Expr> {
|
||||
// First, try direct Json<T>
|
||||
if let Some(inner_type) = extract_json_inner_type(ret_type) {
|
||||
return syn::parse2::<Expr>(quote! {
|
||||
rmcp::handler::server::tool::cached_schema_for_type::<#inner_type>()
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
// Then, try Result<Json<T>, E>
|
||||
let type_path = match ret_type {
|
||||
syn::Type::Path(path) => path,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let last_segment = type_path.path.segments.last()?;
|
||||
|
||||
if last_segment.ident != "Result" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let args = match &last_segment.arguments {
|
||||
syn::PathArguments::AngleBracketed(args) => args,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let ok_type = match args.args.first()? {
|
||||
syn::GenericArgument::Type(ty) => ty,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let inner_type = extract_json_inner_type(ok_type)?;
|
||||
|
||||
syn::parse2::<Expr>(quote! {
|
||||
rmcp::handler::server::tool::cached_schema_for_type::<#inner_type>()
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
// extract doc line from attribute
|
||||
fn extract_doc_line(existing_docs: Option<String>, attr: &syn::Attribute) -> Option<String> {
|
||||
if !attr.path().is_ident("doc") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let syn::Meta::NameValue(name_value) = &attr.meta else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let syn::Expr::Lit(expr_lit) = &name_value.value else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let syn::Lit::Str(lit_str) = &expr_lit.lit else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let content = lit_str.value().trim().to_string();
|
||||
match (existing_docs, content) {
|
||||
(Some(mut existing_docs), content) if !content.is_empty() => {
|
||||
existing_docs.push('\n');
|
||||
existing_docs.push_str(&content);
|
||||
Some(existing_docs)
|
||||
}
|
||||
(Some(existing_docs), _) => Some(existing_docs),
|
||||
(None, content) if !content.is_empty() => Some(content),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
|
||||
let attribute = if attr.is_empty() {
|
||||
Default::default()
|
||||
|
|
@ -202,30 +169,16 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
|
|||
input_schema
|
||||
} else {
|
||||
// try to find some parameters wrapper in the function
|
||||
let params_ty = fn_item.sig.inputs.iter().find_map(|input| {
|
||||
if let syn::FnArg::Typed(pat_type) = input {
|
||||
if let syn::Type::Path(type_path) = &*pat_type.ty {
|
||||
if type_path
|
||||
.path
|
||||
.segments
|
||||
.last()
|
||||
.is_some_and(|type_name| type_name.ident == "Parameters")
|
||||
{
|
||||
return Some(pat_type.ty.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
let params_ty = crate::common::find_parameters_type_impl(&fn_item);
|
||||
if let Some(params_ty) = params_ty {
|
||||
// if found, use the Parameters schema
|
||||
syn::parse2::<Expr>(quote! {
|
||||
rmcp::handler::server::tool::cached_schema_for_type::<#params_ty>()
|
||||
rmcp::handler::server::common::cached_schema_for_type::<#params_ty>()
|
||||
})?
|
||||
} else {
|
||||
// if not found, use the default EmptyObject schema
|
||||
syn::parse2::<Expr>(quote! {
|
||||
rmcp::handler::server::tool::cached_schema_for_type::<rmcp::model::EmptyObject>()
|
||||
rmcp::handler::server::common::cached_schema_for_type::<rmcp::model::EmptyObject>()
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
|
@ -257,7 +210,7 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
|
|||
};
|
||||
syn::parse2::<Expr>(token_stream)?
|
||||
} else {
|
||||
none_expr()
|
||||
none_expr()?
|
||||
};
|
||||
// Handle output_schema - either explicit or generated from return type
|
||||
let output_schema_expr = attribute.output_schema.or_else(|| {
|
||||
|
|
@ -296,10 +249,10 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
|
|||
}
|
||||
match &fn_item.sig.output {
|
||||
syn::ReturnType::Default => {
|
||||
quote! { -> std::pin::Pin<Box<dyn Future<Output = ()> + Send + #lt>> }
|
||||
quote! { -> futures::future::BoxFuture<#lt, ()> }
|
||||
}
|
||||
syn::ReturnType::Type(_, ty) => {
|
||||
quote! { -> std::pin::Pin<Box<dyn Future<Output = #ty> + Send + #lt>> }
|
||||
quote! { -> futures::future::BoxFuture<#lt, #ty> }
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
|
|
|||
Loading…
Reference in a new issue