Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ mod sharelist;
mod statement;
mod support;
mod trade;
mod prompts;

/// Helper to build a JSON Schema `Arc<JsonObject>` from a `JsonSchema`-derived
/// type, suitable for passing to `#[tool(output_schema = ...)]`.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -4181,6 +4183,22 @@ impl ServerHandler for Longbridge {
})
}

async fn list_prompts(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::ListPromptsResult, rmcp::ErrorData> {
Ok(prompts::list_prompts_result())
}

async fn get_prompt(
&self,
request: rmcp::model::GetPromptRequestParams,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::GetPromptResult, rmcp::ErrorData> {
prompts::get_prompt_result(&request.name)
}

async fn list_resources(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
Expand Down Expand Up @@ -4810,6 +4828,16 @@ mod quote_cmd_tests {
);
}

#[test]
fn server_info_declares_prompts_capability() {
let info = <super::Longbridge as rmcp::ServerHandler>::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()`).
///
Expand Down
229 changes: 229 additions & 0 deletions src/tools/prompts.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<Prompt>> = 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<GetPromptResult, rmcp::ErrorData> {
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"
);
}
}
Loading