Skip to main content

tvscreener/mcp/
server.rs

1// Copyright 2026 tvscreener-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! MCP server (`rmcp`) for `tvscreener-mcp` (stdio or Streamable HTTP).
5
6use 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/// MCP server handle exposing screener tools.
27#[derive(Clone, Default)]
28pub struct TvscreenerMcp;
29
30/// Default HTTP bind address for Streamable MCP.
31pub 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    /// Search field catalog by keyword.
44    #[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    /// List field category samples.
68    #[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    /// Flexible query with fields and JSON filters.
81    #[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    /// Simplified stock screen.
111    ///
112    /// `price_range` / `market_cap_billions_range` are optional `"min,max"` strings
113    /// (either side may be empty), e.g. `"10,"`, `",500"`, `"10,100"`.
114    #[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    /// Simplified crypto screen.
148    #[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    /// Simplified forex screen.
172    #[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    /// Top gainers or losers.
189    #[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    /// List preset names.
213    #[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    /// Show one preset's fields.
219    #[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    /// List stock sectors for filtering.
231    #[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    /// List countries.
237    #[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    /// List industries.
243    #[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    /// List exchanges.
249    #[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    /// List recommendation rating bands.
255    #[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    /// List filter operators for `custom_query`.
261    #[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    /// List stock markets.
267    #[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    /// List index symbols for `set_index`.
273    #[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    /// Dry-run scan payload JSON (no HTTP).
281    #[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    /// Live stock screen by index (+ optional markets).
311    #[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
341/// Serves MCP over stdio until the client disconnects.
342///
343/// # Errors
344///
345/// Returns transport / protocol errors from rmcp.
346pub 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
353/// Serves MCP over Streamable HTTP until the process is stopped.
354///
355/// # Errors
356///
357/// Returns I/O errors from binding or serving the Axum listener.
358pub 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}