1use std::sync::Arc;
7
8use rmcp::{
9 handler::server::{tool::ToolCallContext, wrapper::Parameters},
10 model::{
11 CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ListToolsResult,
12 PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool,
13 },
14 service::RequestContext,
15 tool, tool_router,
16 transport::stdio,
17 ErrorData as McpError, RoleServer, ServerHandler, ServiceExt,
18};
19
20use crate::mcp::tool_args::{
21 AssetTypeArgs, BuildPayloadArgs, CustomQueryArgs, DiscoverFieldsArgs, GetPresetArgs,
22 GetTopMoversArgs, SearchByIndexArgs, SearchCryptoArgs, SearchForexArgs, SearchStocksArgs,
23};
24use crate::mcp::tools;
25
26#[derive(Clone, Default)]
28pub struct TvscreenerMcp;
29
30pub const DEFAULT_HTTP_LISTEN: &str = "0.0.0.0:6790";
32
33fn text_ok(text: impl Into<String>) -> CallToolResult {
34 CallToolResult::success(vec![ContentBlock::text(text.into())])
35}
36
37fn text_err(err: impl std::fmt::Display) -> CallToolResult {
38 CallToolResult::error(vec![ContentBlock::text(err.to_string())])
39}
40
41#[tool_router]
42impl TvscreenerMcp {
43 #[tool(
45 description = "Search available screener fields/indicators by keyword (use before custom_query)"
46 )]
47 async fn discover_fields(
48 &self,
49 Parameters(DiscoverFieldsArgs {
50 search_term,
51 asset_type,
52 limit,
53 }): Parameters<DiscoverFieldsArgs>,
54 ) -> Result<CallToolResult, McpError> {
55 let asset = match tools::parse_asset(asset_type.as_deref().unwrap_or("stock")) {
56 Ok(a) => a,
57 Err(err) => return Ok(text_err(err)),
58 };
59 let limit = usize::try_from(limit.unwrap_or(20)).unwrap_or(20);
60 Ok(text_ok(tools::format_discover_fields(
61 asset,
62 &search_term,
63 limit,
64 )))
65 }
66
67 #[tool(description = "List field categories with sample field names for an asset type")]
69 async fn list_field_types(
70 &self,
71 Parameters(AssetTypeArgs { asset_type }): Parameters<AssetTypeArgs>,
72 ) -> Result<CallToolResult, McpError> {
73 let asset = match tools::parse_asset(asset_type.as_deref().unwrap_or("stock")) {
74 Ok(a) => a,
75 Err(err) => return Ok(text_err(err)),
76 };
77 Ok(text_ok(tools::format_field_types(asset)))
78 }
79
80 #[tool(description = "Flexible screener query: fields CSV, filters JSON array, sort_by, limit")]
82 async fn custom_query(
83 &self,
84 Parameters(CustomQueryArgs {
85 asset_type,
86 fields,
87 filters,
88 sort_by,
89 ascending,
90 limit,
91 }): Parameters<CustomQueryArgs>,
92 ) -> Result<CallToolResult, McpError> {
93 Ok(
94 match tools::custom_query(&tools::CustomQueryOpts {
95 asset_type: asset_type.as_deref().unwrap_or("stock"),
96 fields: fields.as_deref(),
97 filters: filters.as_deref(),
98 sort_by: sort_by.as_deref(),
99 ascending: ascending.unwrap_or(false),
100 limit: limit.unwrap_or(25),
101 })
102 .await
103 {
104 Ok(text) => text_ok(text),
105 Err(err) => text_err(err),
106 },
107 )
108 }
109
110 #[tool(
115 description = "Screen stocks; optional price_range and market_cap_billions_range as min,max"
116 )]
117 async fn search_stocks(
118 &self,
119 Parameters(SearchStocksArgs {
120 price_range,
121 market_cap_billions_range,
122 sectors,
123 sort_by,
124 limit,
125 }): Parameters<SearchStocksArgs>,
126 ) -> Result<CallToolResult, McpError> {
127 let (min_price, max_price) = tools::parse_f64_range(price_range.as_deref());
128 let (min_mcap, max_mcap) = tools::parse_f64_range(market_cap_billions_range.as_deref());
129 Ok(
130 match tools::search_stocks(&tools::SearchStocksOpts {
131 min_price,
132 max_price,
133 min_market_cap_billions: min_mcap,
134 max_market_cap_billions: max_mcap,
135 sectors: sectors.as_deref(),
136 sort_by: sort_by.as_deref().unwrap_or("market_cap"),
137 limit: limit.unwrap_or(25),
138 })
139 .await
140 {
141 Ok(text) => text_ok(text),
142 Err(err) => text_err(err),
143 },
144 )
145 }
146
147 #[tool(description = "Screen cryptocurrencies by 24h volume and market cap")]
149 async fn search_crypto(
150 &self,
151 Parameters(SearchCryptoArgs {
152 min_volume_millions,
153 min_market_cap_billions,
154 limit,
155 }): Parameters<SearchCryptoArgs>,
156 ) -> Result<CallToolResult, McpError> {
157 Ok(
158 match tools::search_crypto(
159 min_volume_millions,
160 min_market_cap_billions,
161 limit.unwrap_or(25),
162 )
163 .await
164 {
165 Ok(text) => text_ok(text),
166 Err(err) => text_err(err),
167 },
168 )
169 }
170
171 #[tool(description = "Screen forex currency pairs")]
173 async fn search_forex(
174 &self,
175 Parameters(SearchForexArgs {
176 min_volume_millions,
177 limit,
178 }): Parameters<SearchForexArgs>,
179 ) -> Result<CallToolResult, McpError> {
180 Ok(
181 match tools::search_forex(min_volume_millions, limit.unwrap_or(25)).await {
182 Ok(text) => text_ok(text),
183 Err(err) => text_err(err),
184 },
185 )
186 }
187
188 #[tool(description = "Top gaining or losing stocks/crypto by change percent")]
190 async fn get_top_movers(
191 &self,
192 Parameters(GetTopMoversArgs {
193 asset_type,
194 direction,
195 limit,
196 }): Parameters<GetTopMoversArgs>,
197 ) -> Result<CallToolResult, McpError> {
198 Ok(
199 match tools::get_top_movers(
200 asset_type.as_deref().unwrap_or("stock"),
201 direction.as_deref().unwrap_or("gainers"),
202 limit.unwrap_or(10),
203 )
204 .await
205 {
206 Ok(text) => text_ok(text),
207 Err(err) => text_err(err),
208 },
209 )
210 }
211
212 #[tool(description = "List available field presets (crypto_price, stock_valuation, …)")]
214 async fn list_presets(&self) -> Result<CallToolResult, McpError> {
215 Ok(text_ok(tools::format_list_presets()))
216 }
217
218 #[tool(description = "Show fields included in a named preset")]
220 async fn get_preset(
221 &self,
222 Parameters(GetPresetArgs { name }): Parameters<GetPresetArgs>,
223 ) -> Result<CallToolResult, McpError> {
224 Ok(match tools::format_get_preset(&name) {
225 Ok(text) => text_ok(text),
226 Err(err) => text_err(err),
227 })
228 }
229
230 #[tool(description = "List stock sectors (const → wire) for SECTOR filters / search_stocks")]
232 async fn list_sectors(&self) -> Result<CallToolResult, McpError> {
233 Ok(text_ok(tools::format_list_sectors()))
234 }
235
236 #[tool(description = "List countries (const → wire) for country filters")]
238 async fn list_countries(&self) -> Result<CallToolResult, McpError> {
239 Ok(text_ok(tools::format_list_countries()))
240 }
241
242 #[tool(description = "List industries (const → wire) for industry filters")]
244 async fn list_industries(&self) -> Result<CallToolResult, McpError> {
245 Ok(text_ok(tools::format_list_industries()))
246 }
247
248 #[tool(description = "List exchanges (const → wire) for exchange filters")]
250 async fn list_exchanges(&self) -> Result<CallToolResult, McpError> {
251 Ok(text_ok(tools::format_list_exchanges()))
252 }
253
254 #[tool(description = "List recommendation rating bands (STRONG_BUY … UNKNOWN)")]
256 async fn list_ratings(&self) -> Result<CallToolResult, McpError> {
257 Ok(text_ok(tools::format_list_ratings()))
258 }
259
260 #[tool(description = "List filter operators accepted by custom_query (with JSON examples)")]
262 async fn list_filter_operators(&self) -> Result<CallToolResult, McpError> {
263 Ok(text_ok(tools::format_list_filter_operators()))
264 }
265
266 #[tool(description = "List stock markets (const → wire) for set_markets / build_payload")]
268 async fn list_markets(&self) -> Result<CallToolResult, McpError> {
269 Ok(text_ok(tools::format_list_markets()))
270 }
271
272 #[tool(
274 description = "List index symbolsets (SP500, NASDAQ_100, …) for set_index / search_by_index"
275 )]
276 async fn list_index_symbols(&self) -> Result<CallToolResult, McpError> {
277 Ok(text_ok(tools::format_list_index_symbols()))
278 }
279
280 #[tool(
282 description = "Build scanner POST JSON without calling TradingView; optional stock indices/markets CSV"
283 )]
284 async fn build_payload(
285 &self,
286 Parameters(BuildPayloadArgs {
287 asset_type,
288 fields,
289 filters,
290 sort_by,
291 indices,
292 markets,
293 }): Parameters<BuildPayloadArgs>,
294 ) -> Result<CallToolResult, McpError> {
295 Ok(
296 match tools::build_payload(&tools::BuildPayloadOpts {
297 asset_type: asset_type.as_deref().unwrap_or("stock"),
298 fields: fields.as_deref(),
299 filters: filters.as_deref(),
300 sort_by: sort_by.as_deref(),
301 indices: indices.as_deref(),
302 markets: markets.as_deref(),
303 }) {
304 Ok(text) => text_ok(text),
305 Err(err) => text_err(err),
306 },
307 )
308 }
309
310 #[tool(
312 description = "Screen stocks in index(es); indices CSV e.g. SP500,NASDAQ_100; optional markets CSV"
313 )]
314 async fn search_by_index(
315 &self,
316 Parameters(SearchByIndexArgs {
317 indices,
318 markets,
319 fields,
320 sort_by,
321 limit,
322 }): Parameters<SearchByIndexArgs>,
323 ) -> Result<CallToolResult, McpError> {
324 Ok(
325 match tools::search_by_index(
326 &indices,
327 markets.as_deref(),
328 fields.as_deref(),
329 sort_by.as_deref(),
330 limit.unwrap_or(25),
331 )
332 .await
333 {
334 Ok(text) => text_ok(text),
335 Err(err) => text_err(err),
336 },
337 )
338 }
339}
340
341pub async fn run() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
347 let server = TvscreenerMcp;
348 let service = server.serve(stdio()).await?;
349 service.waiting().await?;
350 Ok(())
351}
352
353pub async fn run_http(addr: &str) -> std::io::Result<()> {
359 let config =
360 rmcp::transport::streamable_http_server::tower::StreamableHttpServerConfig::default();
361 let service = rmcp::transport::streamable_http_server::tower::StreamableHttpService::new(
362 || Ok(TvscreenerMcp),
363 Arc::new(
364 rmcp::transport::streamable_http_server::session::local::LocalSessionManager::default(),
365 ),
366 config,
367 );
368 let method_router = axum::routing::any_service(service);
369 let app = axum::Router::new()
370 .route("/mcp", method_router.clone())
371 .route("/mcp/", method_router);
372 let listener = tokio::net::TcpListener::bind(addr).await?;
373 tracing::info!(%addr, "tvscreener-mcp HTTP listening");
374 axum::serve(listener, app).await?;
375 Ok(())
376}
377
378impl ServerHandler for TvscreenerMcp {
379 fn get_info(&self) -> ServerConfig {
380 ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
381 .with_server_info(rmcp::model::Implementation::new(
382 "tvscreener-rs",
383 env!("CARGO_PKG_VERSION"),
384 ))
385 .with_instructions(
386 "TradingView screener MCP tools: discover fields, custom_query, search helpers, catalog lists, build_payload, search_by_index.",
387 )
388 }
389
390 fn get_tool(&self, name: &str) -> Option<Tool> {
391 Self::tool_router().get(name).cloned()
392 }
393
394 async fn call_tool(
395 &self,
396 request: CallToolRequestParams,
397 context: RequestContext<RoleServer>,
398 ) -> Result<CallToolResponse, McpError> {
399 let tcc = ToolCallContext::new(self, request, context);
400 Self::tool_router().call(tcc).await
401 }
402
403 fn list_tools(
404 &self,
405 _request: Option<PaginatedRequestParams>,
406 _context: RequestContext<RoleServer>,
407 ) -> impl std::future::Future<Output = Result<ListToolsResult, McpError>> + '_ {
408 std::future::ready(Ok(ListToolsResult::with_all_items(
409 Self::tool_router().list_all(),
410 )))
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn mcp_server_version_matches_crate() {
420 let info = TvscreenerMcp.get_info();
421 assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
422 assert_eq!(info.server_info.name.as_str(), "tvscreener-rs");
423 }
424}