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}