Skip to main content

southesk/client/
connected.rs

1// Copyright 2026 Thomas Axelsson
2// SPDX-License-Identifier: MIT
3
4use std::{borrow::Cow, time::Duration};
5
6use rmcp::model::{CallToolRequestParams, CallToolResult};
7#[cfg(feature = "__dev")]
8use serde::Serialize;
9use serde::de::DeserializeOwned;
10use tracing::{debug, error, info, warn};
11
12use crate::{
13    Disconnected,
14    error::ClientCallError,
15    raw::JsonObject,
16    types::{
17        AccountFilter, AccountHoldings, AccountIdentifiers, CreateTradeTicketResult,
18        InstrumentIdentifiers, ModifyWatchlistResult, TradeCurrency, TradeTicketArgs, Watchlist,
19        WatchlistInfo,
20    },
21};
22
23use super::{Client, Connected};
24
25/// # Client state
26impl Client<Connected> {
27    /// Disconnects from the MCP server.
28    pub async fn disconnect(mut self) -> Client<Disconnected> {
29        info!("Disconnecting from the MCP server...");
30        match self
31            .state
32            .client
33            .close_with_timeout(Duration::from_secs(30))
34            .await
35        {
36            Ok(None) => warn!("MCP service did not shut down cleanly"),
37            Ok(Some(quit_reason)) => debug!("MCP service exited: {quit_reason:?}"),
38            Err(e) => error!("Failed to join MCP service task: {e}"),
39        }
40        info!("Disconnected from the MCP server");
41
42        Client {
43            name: self.name,
44            auth_handler: self.auth_handler,
45            cred_store: self.cred_store,
46            state: Disconnected,
47        }
48    }
49}
50
51/// # High-level API
52///
53/// Each method maps directly to a Montrose MCP tool of the same name.
54impl Client<Connected> {
55    // BUILD: HIGH-LEVEL START
56
57    /// Returns holdings for either one account (when
58    /// [`AccountFilter::AccountId`] is provided) or all accessible accounts.
59    /// Each account includes
60    /// [`currency_positions`](AccountHoldings::currency_positions): the
61    /// account's multi-currency cash balances with the amount available for
62    /// purchase per currency. An empty
63    /// [`currency_positions`](AccountHoldings::currency_positions) list means
64    /// the account holds cash only in its main currency (see
65    /// [`summary`](AccountHoldings::summary)). Use
66    /// [`get_user_accounts`](Self::get_user_accounts) first to find valid
67    /// account IDs.
68    pub async fn get_holdings(
69        &self,
70        selection: AccountFilter,
71    ) -> Result<Vec<AccountHoldings>, ClientCallError> {
72        let mut args = serde_json::Map::new();
73        args.insert(
74            "accountId".to_string(),
75            match selection {
76                AccountFilter::AccountId(account_id) => Some(account_id.to_string()),
77                AccountFilter::All => None,
78            }
79            .into(),
80        );
81        self.api_call("get_holdings", Some(args)).await
82    }
83
84    /// Returns all user accounts with stable account IDs and display names. Use
85    /// this tool to discover valid account IDs before calling
86    /// [`get_holdings`](Self::get_holdings) for a specific account.
87    pub async fn get_user_accounts(&self) -> Result<Vec<AccountIdentifiers>, ClientCallError> {
88        self.api_call("get_user_accounts", None).await
89    }
90
91    /// Creates a pre-filled trade ticket URL for the Montrose app. Specify side
92    /// (Buy/Sell), quantity or amount, and an instrument identifier. Use
93    /// orderbookId directly when known, since it is the safest identifier. If
94    /// you only know a ticker or name and it may be ambiguous, call
95    /// [`search_instruments`](Self::search_instruments) first to find the
96    /// correct orderbookId, then call
97    /// [`create_trade_ticket`](Self::create_trade_ticket). Returns a URL that
98    /// opens the trade ticket in the Montrose app with the order details
99    /// pre-filled.
100    pub async fn create_trade_ticket(
101        &self,
102        args: TradeTicketArgs,
103    ) -> Result<reqwest::Url, ClientCallError> {
104        if args.currency == TradeCurrency::Account && args.account_id.is_none() {
105            return Err(ClientCallError::InvalidArguments(
106                "either currency or account_id must be set".to_string(),
107            ));
108        }
109        let arg_map = match serde_json::to_value(args) {
110            Ok(serde_json::Value::Object(map)) => map,
111            Ok(_) => {
112                return Err(ClientCallError::InvalidArguments(
113                    "could not convert args to JSON object".to_string(),
114                ));
115            }
116            Err(_) => {
117                return Err(ClientCallError::InvalidArguments(
118                    "could not convert args to JSON".to_string(),
119                ));
120            }
121        };
122        self.api_call::<CreateTradeTicketResult>("create_trade_ticket", Some(arg_map))
123            .await
124            .map(|res| res.url)
125    }
126
127    /// Searches instruments by ticker or name and returns matching
128    /// orderbookIds, tickers, and names. Use this tool before
129    /// [`create_trade_ticket`](Self::create_trade_ticket) when multiple
130    /// instruments have similar names.
131    ///
132    /// Seems to return at most 9 results.
133    pub async fn search_instruments(
134        &self,
135        query: &str,
136    ) -> Result<Vec<InstrumentIdentifiers>, ClientCallError> {
137        let mut arg_map = serde_json::Map::new();
138        arg_map.insert("query".to_string(), query.into());
139        self.api_call("search_instruments", Some(arg_map)).await
140    }
141
142    /// Returns the authenticated user's watchlists with their ID, name, and the
143    /// number of instruments on each list. Use [`get_watchlist`](Self::get_watchlist) with a listId to
144    /// read the instruments on a specific watchlist.
145    pub async fn get_watchlists(&self) -> Result<Vec<WatchlistInfo>, ClientCallError> {
146        self.api_call("get_watchlists", None).await
147    }
148
149    /// Returns the instruments on a single watchlist, identified by `list_id`.
150    /// Each instrument is enriched with its orderbookId, ticker and name. Use
151    /// [`get_watchlists`](Self::get_watchlists) first to discover valid listIds.
152    pub async fn get_watchlist(&self, list_id: u64) -> Result<Watchlist, ClientCallError> {
153        let mut arg_map = serde_json::Map::new();
154        arg_map.insert("listId".to_string(), list_id.into());
155        self.api_call("get_watchlist", Some(arg_map)).await
156    }
157
158    /// Creates a new watchlist with the given name for the authenticated user.
159    /// If a watchlist with the same name already exists, returns that existing
160    /// watchlist.
161    #[doc(alias = "create_or_get_watchlist")]
162    pub async fn create_watchlist(&self, name: &str) -> Result<WatchlistInfo, ClientCallError> {
163        let mut arg_map = serde_json::Map::new();
164        arg_map.insert("name".to_string(), name.into());
165        self.api_call("create_watchlist", Some(arg_map)).await
166    }
167
168    /// Adds one or more instruments to an existing watchlist by orderbookId.
169    /// Use [`search_instruments`](Self::search_instruments) to find the correct
170    /// orderbookId for a ticker or name. Instruments already on the watchlist
171    /// are silently skipped.
172    pub async fn add_to_watchlist(
173        &self,
174        list_id: u64,
175        orderbook_ids: &[u64],
176    ) -> Result<ModifyWatchlistResult, ClientCallError> {
177        let mut arg_map = serde_json::Map::new();
178        arg_map.insert("listId".to_string(), list_id.into());
179        arg_map.insert("orderbookIds".to_string(), orderbook_ids.into());
180        self.api_call("add_to_watchlist", Some(arg_map)).await
181    }
182
183    /// Removes one or more instruments from a watchlist by orderbookId. Returns
184    /// the orderbookIds that were actually removed; orderbookIds that were not
185    /// on the watchlist are silently ignored and excluded from the response.
186    pub async fn remove_from_watchlist(
187        &self,
188        list_id: u64,
189        orderbook_ids: &[u64],
190    ) -> Result<ModifyWatchlistResult, ClientCallError> {
191        let mut arg_map = serde_json::Map::new();
192        arg_map.insert("listId".to_string(), list_id.into());
193        arg_map.insert("orderbookIds".to_string(), orderbook_ids.into());
194        self.api_call("remove_from_watchlist", Some(arg_map)).await
195    }
196
197    // BUILD: HIGH-LEVEL END
198}
199
200/// # Raw API
201///
202/// The following methods provides raw API access to the MCP.
203#[cfg(feature = "raw-api")]
204impl Client<Connected> {
205    /// Raw API. Calls the specified MCP tool with the given arguments.
206    ///
207    /// This function can be used before a new MCP API tool has been added to
208    /// southesk.
209    ///
210    /// Set `T` as a type that implements `Deserialize` that matches the
211    /// expected response format. You can use [`serde_json::Value`] when the
212    /// format is unknown.
213    ///
214    /// This is a raw API call. Prefer using the higher-level methods when
215    /// available.
216    ///
217    /// # Examples
218    ///
219    /// ```no_run
220    /// # use southesk::ClientBuilder;
221    /// # tokio_test::block_on(
222    /// # async {
223    /// # let client = ClientBuilder::new("My Montrose client").build().await?;
224    /// # let client = client.connect().await?;
225    /// use southesk::raw::json_object;
226    ///
227    /// let result: serde_json::Value = client
228    ///     .raw_tool_call::<serde_json::Value>(
229    ///         "get_holdings",
230    ///         Some(json_object!({"accountId": "771c4286-991c-48aa-965e-c7dd62e31735"})))
231    ///     .await?;
232    /// # Ok::<(), Box<dyn std::error::Error>>(())
233    /// # });
234    /// ```
235    ///
236    /// ```no_run
237    /// # use southesk::ClientBuilder;
238    /// # tokio_test::block_on(
239    /// # async {
240    /// # let client = ClientBuilder::new("My Montrose client").build().await?;
241    /// # let client = client.connect().await?;
242    /// let mut args = serde_json::Map::new();
243    /// args.insert(
244    ///     "accountId".to_string(),
245    ///     "771c4286-991c-48aa-965e-c7dd62e31735".into(),
246    /// );
247    /// let result: serde_json::Value = client
248    ///     .raw_tool_call::<serde_json::Value>("get_holdings", Some(args))
249    ///     .await?;
250    /// # Ok::<(), Box<dyn std::error::Error>>(())
251    /// # });
252    /// ```
253    pub async fn raw_tool_call<T: DeserializeOwned>(
254        &self,
255        tool_name: impl Into<Cow<'static, str>>,
256        args: Option<JsonObject>,
257    ) -> Result<T, ClientCallError> {
258        self.api_call(tool_name, args).await
259    }
260}
261
262// Helpers
263impl Client<Connected> {
264    /// Calls the specified MCP tool with the given arguments.
265    pub(crate) async fn api_call<T: DeserializeOwned>(
266        &self,
267        tool_name: impl Into<Cow<'static, str>>,
268        args: Option<JsonObject>,
269    ) -> Result<T, ClientCallError> {
270        let req = CallToolRequestParams::new(tool_name);
271        let req = if let Some(args) = args {
272            req.with_arguments(args)
273        } else {
274            req
275        };
276        debug!("Call request: {:#?}", req);
277        let res = self.state.client.call_tool(req).await?;
278        debug!("Call response: {:#?}", res);
279        parse_result::<T>(&res)
280    }
281}
282
283// Development utilities
284#[cfg(feature = "__dev")]
285#[doc(hidden)]
286impl Client<Connected> {
287    /// Fetches and prints available tools and prompts from the server.
288    /// Used for southesk development.
289    ///
290    /// # Panics
291    /// Panics if writing to the result string fails.
292    pub async fn introspect(&self) -> String {
293        fn parse<T: Serialize, E: std::error::Error>(
294            d: &str,
295            res: Result<Vec<T>, E>,
296            json_result: &mut serde_json::Map<String, serde_json::Value>,
297        ) {
298            match res {
299                Ok(items) => {
300                    info!("Available {d}: {}", items.len());
301                    json_result.insert(d.to_string(), serde_json::to_value(&items).unwrap());
302                }
303                Err(e) => {
304                    warn!("Error fetching {d}: {e}");
305                    json_result.insert(d.to_string(), serde_json::Value::Null);
306                }
307            }
308        }
309
310        let mut json_result = serde_json::Map::new();
311
312        // Server info provides the server version, but not versioning of the
313        // tools.
314        let server_info = &self
315            .state
316            .client
317            .peer_info()
318            .expect("failed to get server info")
319            .server_info;
320        json_result.insert(
321            "server".to_string(),
322            serde_json::to_value(server_info).unwrap(),
323        );
324        parse(
325            "tools",
326            self.state.client.peer().list_all_tools().await,
327            &mut json_result,
328        );
329        parse(
330            "prompts",
331            self.state.client.peer().list_all_prompts().await,
332            &mut json_result,
333        );
334        parse(
335            "resources",
336            self.state.client.peer().list_all_resources().await,
337            &mut json_result,
338        );
339        serde_json::to_string_pretty(&json_result).unwrap()
340    }
341}
342
343fn parse_result<T: DeserializeOwned>(res: &CallToolResult) -> Result<T, ClientCallError> {
344    let text = &res
345        .content
346        .first()
347        .ok_or(ClientCallError::parse_err("no content element in response"))?
348        .raw
349        .as_text()
350        .ok_or(ClientCallError::parse_err("no raw text in response"))?
351        .text;
352    if res.is_error.unwrap_or(false) {
353        return Err(ClientCallError::McpError(text.clone()));
354    }
355    serde_json::from_str::<T>(text).map_err(|e| ClientCallError::ParseError {
356        msg: format!("failed to parse response text: {text}"),
357        source: Some(e.into()),
358    })
359}