diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 643344b..1e93b61 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -73,6 +73,7 @@ mod sharelist; mod statement; mod support; mod trade; +mod prompts; /// Helper to build a JSON Schema `Arc` from a `JsonSchema`-derived /// type, suitable for passing to `#[tool(output_schema = ...)]`. @@ -4060,6 +4061,7 @@ impl ServerHandler for Longbridge { rmcp::model::ServerCapabilities::builder() .enable_tools() .enable_resources() + .enable_prompts() .build(), ) .with_server_info(rmcp::model::Implementation::new( @@ -4181,6 +4183,22 @@ impl ServerHandler for Longbridge { }) } + async fn list_prompts( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> Result { + Ok(prompts::list_prompts_result()) + } + + async fn get_prompt( + &self, + request: rmcp::model::GetPromptRequestParams, + _context: rmcp::service::RequestContext, + ) -> Result { + prompts::get_prompt_result(&request.name) + } + async fn list_resources( &self, _request: Option, @@ -4810,6 +4828,16 @@ mod quote_cmd_tests { ); } + #[test] + fn server_info_declares_prompts_capability() { + let info = ::get_info(&super::Longbridge); + + assert!( + info.capabilities.prompts.is_some(), + "prompts capability must be advertised after enable_prompts()" + ); + } + /// Measures the speedup of the cached tool list vs. the old rebuild-on-every-call path. /// Also benchmarks the actual production hot path (`tools_main_endpoint().to_vec()`). /// diff --git a/src/tools/prompts.rs b/src/tools/prompts.rs new file mode 100644 index 0000000..1da54b1 --- /dev/null +++ b/src/tools/prompts.rs @@ -0,0 +1,229 @@ +//! Predefined MCP prompt templates for common investment workflows. + +use std::sync::OnceLock; + +use rmcp::model::{GetPromptResult, ListPromptsResult, Prompt, PromptMessage, PromptMessageRole}; + +const NVDA_ANALYSIS_TEXT: &str = "\ +Please analyze NVDA.US comprehensively using the available tools: + +1. Recent price action: fetch the latest quote (use `quote`) and 30-day daily candlestick data \ +(use `candlesticks`) to describe the price trend and momentum. +2. Latest earnings: retrieve the most recent financial report (use `financial_report_latest`) \ +to determine whether NVIDIA beat or missed EPS and revenue estimates versus analyst consensus \ +(use `forecast_eps`). +3. Profit margins: extract gross margin, operating margin, and net margin from the financial \ +report or financial statement. +4. Forward guidance: summarize management guidance from the latest report and EPS forecast \ +revisions. +5. Analyst outlook: retrieve institution ratings (use `institution_rating`) and analyst \ +consensus (use `consensus`) to summarize the current Buy/Hold/Sell distribution and median \ +price target. + +Synthesize the above into a concise investment brief for NVDA.US."; + +const COMPARE_STOCKS_TEXT: &str = "\ +Please compare AAPL.US, TSLA.US, and NVDA.US across three dimensions to identify the best \ +risk/reward opportunity: + +1. Valuation: fetch P/E, P/B, and dividend yield for each stock (use `calc_indexes` or \ +`valuation`). +2. Revenue growth: retrieve the latest financial reports (use `financial_report_latest`) for \ +each ticker and compute year-over-year revenue growth rates. +3. Free cash flow: extract operating cash flow and capital expenditures from the latest \ +financial statements (use `financial_statement`) to compute FCF and FCF yield. + +Present the results in a comparison table, then conclude with a relative ranking and your view \ +on which stock offers the best risk/reward at current prices."; + +const PORTFOLIO_REVIEW_TEXT: &str = "\ +Please review my Longbridge investment portfolio using the available tools: + +1. Allocation: fetch my current stock holdings (use `stock_positions`) and fund holdings \ +(use `fund_positions`) to show each position's size and its weight in the total portfolio value. +2. P&L drivers: use `profit_analysis` to identify which positions are the primary drivers of \ +portfolio gains and losses. +3. Key risks: flag any positions with high concentration (>20% of total portfolio), significant \ +unrealized losses (>15%), or unusually high beta/volatility. +4. Overvalued holdings: cross-reference current holdings with valuation metrics (P/E, P/B) via \ +`calc_indexes` or `valuation` to identify any holdings trading at elevated multiples relative \ +to sector peers. + +Provide a structured summary with clear sections for each category and actionable observations \ +where relevant."; + +/// Returns the static list of all registered prompts. +pub(crate) fn all_prompts() -> &'static [Prompt] { + static PROMPTS: OnceLock> = OnceLock::new(); + PROMPTS.get_or_init(|| { + vec![ + Prompt::new( + "nvda_analysis", + Some("Analyze NVDA.US: recent price action, earnings beat/miss, profit margins, forward guidance, and analyst outlook"), + None, + ), + Prompt::new( + "compare_stocks", + Some("Compare AAPL.US, TSLA.US, and NVDA.US on valuation, revenue growth, and free cash flow to identify the better buy"), + None, + ), + Prompt::new( + "portfolio_review", + Some("Review my portfolio: allocation breakdown, key risks, P&L drivers, and potentially overvalued holdings"), + None, + ), + ] + }) +} + +/// Builds a `GetPromptResult` for the named prompt, or returns an `invalid_params` +/// error for unknown names. +pub(crate) fn get_prompt_result(name: &str) -> Result { + let text = match name { + "nvda_analysis" => NVDA_ANALYSIS_TEXT, + "compare_stocks" => COMPARE_STOCKS_TEXT, + "portfolio_review" => PORTFOLIO_REVIEW_TEXT, + _ => { + return Err(rmcp::ErrorData::invalid_params("unknown prompt", None)); + } + }; + Ok(GetPromptResult::new(vec![PromptMessage::new_text( + PromptMessageRole::User, + text, + )])) +} + +/// Returns `ListPromptsResult` with all registered prompts. +pub(crate) fn list_prompts_result() -> ListPromptsResult { + ListPromptsResult::with_all_items(all_prompts().to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_prompts_returns_three() { + assert_eq!(all_prompts().len(), 3, "expected exactly 3 prompts"); + } + + #[test] + fn all_prompts_names_are_correct() { + let names: Vec<&str> = all_prompts().iter().map(|p| p.name.as_str()).collect(); + assert!( + names.contains(&"nvda_analysis"), + "nvda_analysis must be registered" + ); + assert!( + names.contains(&"compare_stocks"), + "compare_stocks must be registered" + ); + assert!( + names.contains(&"portfolio_review"), + "portfolio_review must be registered" + ); + } + + #[test] + fn all_prompts_have_non_empty_descriptions() { + for prompt in all_prompts() { + assert!( + prompt + .description + .as_deref() + .is_some_and(|d| !d.is_empty()), + "prompt {:?} must have a non-empty description", + prompt.name + ); + } + } + + #[test] + fn all_prompts_have_no_arguments() { + for prompt in all_prompts() { + assert!( + prompt.arguments.as_ref().map_or(true, |a| a.is_empty()), + "prompt {:?} should have no arguments", + prompt.name + ); + } + } + + #[test] + fn get_prompt_nvda_analysis_returns_ok() { + assert!( + get_prompt_result("nvda_analysis").is_ok(), + "nvda_analysis must return Ok" + ); + } + + #[test] + fn get_prompt_compare_stocks_returns_ok() { + assert!( + get_prompt_result("compare_stocks").is_ok(), + "compare_stocks must return Ok" + ); + } + + #[test] + fn get_prompt_portfolio_review_returns_ok() { + assert!( + get_prompt_result("portfolio_review").is_ok(), + "portfolio_review must return Ok" + ); + } + + #[test] + fn get_prompt_unknown_returns_error() { + assert!( + get_prompt_result("unknown_prompt").is_err(), + "unknown prompt name must return Err" + ); + } + + #[test] + fn nvda_analysis_text_contains_nvda_symbol() { + assert!( + NVDA_ANALYSIS_TEXT.contains("NVDA.US"), + "nvda_analysis text must reference NVDA.US" + ); + } + + #[test] + fn compare_stocks_text_references_all_three_tickers() { + assert!( + COMPARE_STOCKS_TEXT.contains("AAPL.US"), + "must reference AAPL.US" + ); + assert!( + COMPARE_STOCKS_TEXT.contains("TSLA.US"), + "must reference TSLA.US" + ); + assert!( + COMPARE_STOCKS_TEXT.contains("NVDA.US"), + "must reference NVDA.US" + ); + } + + #[test] + fn portfolio_review_text_references_required_tools() { + assert!( + PORTFOLIO_REVIEW_TEXT.contains("stock_positions"), + "must reference stock_positions" + ); + assert!( + PORTFOLIO_REVIEW_TEXT.contains("profit_analysis"), + "must reference profit_analysis" + ); + } + + #[test] + fn list_prompts_result_has_three_items() { + let result = list_prompts_result(); + assert_eq!( + result.prompts.len(), + 3, + "list_prompts must return exactly 3 prompts" + ); + } +}