pub mod bond;
pub mod crypto;
pub mod economy;
pub mod forex;
pub mod fund;
pub mod futures;
pub mod index;
pub mod macro_data;
pub mod news;
pub mod option;
pub mod stock;
use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
use akshare::AkShareClient;
use rmcp::{
ErrorData as McpError, RoleServer, ServerHandler,
handler::server::tool::schema_for_type,
model::{
CallToolRequestParams, CallToolResult, Content, Implementation, PaginatedRequestParams,
ServerCapabilities, ServerInfo, Tool,
},
schemars,
service::RequestContext,
};
use crate::config::ToolsConfig;
type Handler = fn(
&AkShareClient,
serde_json::Value,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<serde_json::Value, String>> + Send>,
>;
pub(crate) struct ToolEntry {
pub description: &'static str,
pub category: &'static str,
pub schema: Arc<serde_json::Map<String, serde_json::Value>>,
pub handler: Handler,
}
macro_rules! register_tool {
($registry:expr, $name:expr, $desc:expr, $cat:expr, $ptype:ty, |$client:ident, $p:ident| $body:expr) => {{
let handler: crate::tools::Handler = |client, args| {
Box::pin({
let $client = client.clone();
async move {
let $p: $ptype = serde_json::from_value(args).map_err(|e| e.to_string())?;
$body.await
}
})
};
$registry.insert(
$name,
crate::tools::ToolEntry {
description: $desc,
category: $cat,
schema: rmcp::handler::server::tool::schema_for_type::<$ptype>(),
handler,
},
);
}};
}
include!(concat!(env!("OUT_DIR"), "/tool_registry.rs"));
static TOOL_REGISTRY: LazyLock<HashMap<&'static str, ToolEntry>> = LazyLock::new(|| {
let mut m = HashMap::new();
load_registry(&mut m);
m
});
const CATEGORY_PREFIXES: &[(&str, &[&str])] = &[
("stock", &["stock_", "a_share_", "hk_", "us_", "get_us_"]),
("bond", &["bond_"]),
("index", &["index_"]),
("futures", &["futures_"]),
(
"economy",
&[
"economy_",
"movie_",
"nlp_",
"amac_",
"car_",
"sw_",
"fx_",
"article_",
"air_",
"qdii_",
"video_",
"sunrise_",
"repo_",
"migration_",
"fred_",
"xincaifu_",
"spot_",
"sogou_",
"rate_",
"online_",
"match_",
"hurun_",
"hf_",
"google_",
"gdelt_",
"game_",
"forbes_",
"drewry_",
"business_",
"bing_",
"baidu_",
"qhkc_",
"methods_in_",
],
),
("crypto", &["crypto_"]),
("forex", &["forex_", "currency_"]),
("option", &["option_"]),
("news", &["news_"]),
("macro_data", &["macro_"]),
("fund", &["fund_"]),
];
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct ToolsSearchParams {
query: Option<String>,
category: Option<String>,
}
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct ToolsCallParams {
name: String,
arguments: Option<serde_json::Map<String, serde_json::Value>>,
}
#[derive(Clone)]
pub struct AkShareMcpService {
client: AkShareClient,
enabled_categories: Vec<String>,
}
impl AkShareMcpService {
#[must_use]
pub fn new(tools_config: ToolsConfig) -> Self {
Self {
client: AkShareClient::new(),
enabled_categories: Self::enabled_categories(&tools_config),
}
}
fn enabled_categories(config: &ToolsConfig) -> Vec<String> {
CATEGORY_PREFIXES
.iter()
.filter(|(cat, _)| config.is_enabled(cat))
.map(|(cat, _)| (*cat).to_string())
.collect()
}
fn is_category_enabled(&self, category: &str) -> bool {
self.enabled_categories.iter().any(|c| c == category)
}
fn meta_tools() -> Vec<Tool> {
vec![
Tool::new_with_raw(
"tools/search",
Some("Search available tools by keyword and/or category. Returns matching tool names and descriptions. Use this before tools/call to discover the exact tool name.".into()),
schema_for_type::<ToolsSearchParams>(),
),
Tool::new_with_raw(
"tools/call",
Some("Call any tool by name with JSON arguments. Use tools/search first to discover available tools and their parameter schemas.".into()),
schema_for_type::<ToolsCallParams>(),
),
]
}
fn search_registry(
&self,
category: Option<&str>,
query: Option<&str>,
) -> Vec<(String, String)> {
let query_lower = query.map(|q| q.to_lowercase());
let category_prefixes: Option<&[&str]> = category
.and_then(|cat| CATEGORY_PREFIXES.iter().find(|(name, _)| *name == cat))
.map(|(_, prefixes)| *prefixes);
TOOL_REGISTRY
.iter()
.filter(|(name, entry)| {
if !self.is_category_enabled(entry.category) {
return false;
}
if let Some(prefixes) = category_prefixes
&& !prefixes.iter().any(|p| name.starts_with(p))
{
return false;
}
if let Some(ref q) = query_lower {
let name_match = name.to_lowercase().contains(q.as_str());
let desc_match = entry.description.to_lowercase().contains(q.as_str());
if !name_match && !desc_match {
return false;
}
}
true
})
.map(|(name, entry)| (name.to_string(), entry.description.to_string()))
.collect()
}
}
impl ServerHandler for AkShareMcpService {
async fn call_tool(
&self,
request: CallToolRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
match request.name.as_ref() {
"tools/search" => {
let args: ToolsSearchParams = request
.arguments
.map(|obj| {
serde_json::from_value(serde_json::Value::Object(obj)).map_err(|e| {
McpError::invalid_params(
format!("failed to deserialize parameters: {e}"),
None,
)
})
})
.transpose()?
.unwrap_or(ToolsSearchParams {
query: None,
category: None,
});
let results: Vec<serde_json::Value> = self
.search_registry(args.category.as_deref(), args.query.as_deref())
.into_iter()
.map(|(name, desc)| serde_json::json!({ "name": name, "description": desc }))
.collect();
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&results).unwrap(),
)]))
}
"tools/call" => {
let args: ToolsCallParams = request
.arguments
.map(|obj| {
serde_json::from_value(serde_json::Value::Object(obj)).map_err(|e| {
McpError::invalid_params(
format!("failed to deserialize parameters: {e}"),
None,
)
})
})
.transpose()?
.ok_or_else(|| McpError::invalid_params("missing arguments", None))?;
let entry = TOOL_REGISTRY.get(args.name.as_str()).ok_or_else(|| {
McpError::invalid_params(format!("unknown tool: {}", args.name), None)
})?;
if !self.is_category_enabled(entry.category) {
return Err(McpError::invalid_params(
format!("tool category '{}' is disabled", entry.category),
None,
));
}
let args_value = args
.arguments
.map(serde_json::Value::Object)
.unwrap_or(serde_json::Value::Null);
let handler = entry.handler;
let result = handler(&self.client, args_value)
.await
.map_err(|e| McpError::internal_error(e, None))?;
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&result).unwrap(),
)]))
}
_ => Err(McpError::invalid_params(
format!(
"unknown tool: {}. Use tools/search to discover available tools.",
request.name
),
None,
)),
}
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<rmcp::model::ListToolsResult, McpError> {
let mut tools = Self::meta_tools();
for (name, entry) in TOOL_REGISTRY.iter() {
if self.is_category_enabled(entry.category) {
tools.push(Tool::new_with_raw(
*name,
Some(entry.description.into()),
entry.schema.clone(),
));
}
}
Ok(rmcp::model::ListToolsResult {
tools,
meta: None,
next_cursor: None,
})
}
fn get_tool(&self, name: &str) -> Option<Tool> {
if name == "tools/search" || name == "tools/call" {
return Self::meta_tools()
.into_iter()
.find(|t| t.name.as_ref() == name);
}
TOOL_REGISTRY.get(name).and_then(|entry| {
if self.is_category_enabled(entry.category) {
Some(Tool::new_with_raw(
name.to_string(),
Some(entry.description.into()),
entry.schema.clone(),
))
} else {
None
}
})
}
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::from_build_env())
.with_instructions(
"Financial market data MCP server powered by akshare-rs. \
Use tools/search to discover available tools, then tools/call to invoke them. \
Provides tools for A-share, HK, US stocks, funds, bonds, \
futures, options, forex, crypto, macro data, economy, and news."
.to_string(),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_service_creates() {
let service = AkShareMcpService::new(ToolsConfig::all());
let info = service.get_info();
assert!(info.capabilities.tools.is_some());
}
#[test]
fn test_service_info_instructions() {
let service = AkShareMcpService::new(ToolsConfig::all());
let info = service.get_info();
let instructions = info.instructions.unwrap();
assert!(instructions.contains("akshare-rs"));
}
#[test]
fn test_service_clone() {
let service = AkShareMcpService::new(ToolsConfig::all());
let cloned = service;
let info = cloned.get_info();
assert!(info.capabilities.tools.is_some());
}
#[test]
fn test_list_tools_has_meta_tools() {
let service = AkShareMcpService::new(ToolsConfig::all());
let tools = service.list_tools_sync();
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
assert!(names.contains(&"tools/search"));
assert!(names.contains(&"tools/call"));
}
#[test]
fn test_list_tools_has_registry_tools() {
let service = AkShareMcpService::new(ToolsConfig::all());
let tools = service.list_tools_sync();
assert!(tools.len() > 2, "expected registry tools to be listed");
}
#[test]
fn test_get_tool_meta_tools() {
let service = AkShareMcpService::new(ToolsConfig::all());
assert!(service.get_tool("tools/search").is_some());
assert!(service.get_tool("tools/call").is_some());
}
#[test]
fn test_category_filter() {
let cfg = ToolsConfig {
bond: true,
..ToolsConfig::default()
};
let service = AkShareMcpService::new(cfg);
let results = service.search_registry(None, None);
let has_bond = results.iter().any(|(n, _)| n.starts_with("bond_"));
let has_futures = results.iter().any(|(n, _)| n.starts_with("futures_"));
assert!(has_bond, "bond tools should be enabled");
assert!(!has_futures, "futures tools should be disabled");
}
#[test]
fn test_search_by_keyword() {
let service = AkShareMcpService::new(ToolsConfig::all());
let results = service.search_registry(None, Some("quote"));
assert!(!results.is_empty(), "searching 'quote' should find tools");
for (name, desc) in &results {
assert!(
name.to_lowercase().contains("quote") || desc.to_lowercase().contains("quote"),
"result should match query: {}",
name
);
}
}
#[test]
fn test_search_by_category() {
let service = AkShareMcpService::new(ToolsConfig::all());
let results = service.search_registry(Some("stock"), None);
assert!(!results.is_empty(), "stock category should have tools");
for (name, _) in &results {
assert!(
name.starts_with("stock_")
|| name.starts_with("a_share_")
|| name.starts_with("hk_")
|| name.starts_with("us_")
|| name.starts_with("get_us_"),
"all results should be stock tools, got: {}",
name
);
}
}
impl AkShareMcpService {
fn list_tools_sync(&self) -> Vec<Tool> {
let mut tools = Self::meta_tools();
for (name, entry) in TOOL_REGISTRY.iter() {
if self.is_category_enabled(entry.category) {
tools.push(Tool::new_with_raw(
*name,
Some(entry.description.into()),
entry.schema.clone(),
));
}
}
tools
}
}
}