southesk 0.0.1

A Rust client library for the Montrose MCP API.
Documentation
// Copyright 2026 Thomas Axelsson
// SPDX-License-Identifier: MIT

use rmcp::model::{CallToolRequestParams, CallToolResult, JsonObject};
use serde::de::DeserializeOwned;
use std::fmt::Write;
use tracing::debug;

use crate::{
    ClientCallError,
    types::{
        Account, AccountInfo, CreateTradeTicketResult, HoldingsSelector, RemoveFromWatchlistResult,
        TradeInstrumentInfo, TradeTicketArgs, Watchlist, WatchlistInfo,
    },
};

use super::{Client, Connected};

impl Client<Connected> {
    /// Returns holdings for either one account (when [`HoldingsSelector::AccountId`] is provided) or
    /// all accessible accounts. Use
    /// [`get_user_accounts`](Self::get_user_accounts) first to find valid
    /// account IDs.
    pub async fn get_holdings(
        &self,
        selection: HoldingsSelector,
    ) -> Result<Vec<Account>, ClientCallError> {
        let mut args = serde_json::Map::new();
        args.insert(
            "accountId".to_string(),
            match selection {
                HoldingsSelector::AccountId(account_id) => Some(account_id.to_string()),
                HoldingsSelector::All => None,
            }
            .into(),
        );
        self.api_call("get_holdings", Some(args)).await
    }

    /// Returns all user accounts with stable account IDs and display names. Use
    /// this tool to discover valid account IDs before calling
    /// [`get_holdings`](Self::get_holdings) for a specific account.
    pub async fn get_user_accounts(&self) -> Result<Vec<AccountInfo>, ClientCallError> {
        self.api_call("get_user_accounts", None).await
    }

    /// Creates a pre-filled trade ticket URL for the Montrose app. Specify side
    /// (Buy/Sell), quantity or amount, and an instrument identifier. Use
    /// orderbookId directly when known, since it is the safest identifier. If
    /// you only know a ticker or name and it may be ambiguous, call
    /// [`search_instruments`](Self::search_instruments) first to find the
    /// correct orderbookId, then call
    /// [`create_trade_ticket`](Self::create_trade_ticket). Returns a URL that
    /// opens the trade ticket in the Montrose app with the order details
    /// pre-filled.
    pub async fn create_trade_ticket(
        &self,
        args: TradeTicketArgs,
    ) -> Result<reqwest::Url, ClientCallError> {
        let arg_map = match serde_json::to_value(args) {
            Ok(serde_json::Value::Object(map)) => map,
            Ok(_) => {
                return Err(ClientCallError::InvalidArguments(
                    "Could not convert args to JSON object".to_string(),
                ));
            }
            Err(_) => {
                return Err(ClientCallError::InvalidArguments(
                    "Could not convert args to JSON".to_string(),
                ));
            }
        };
        self.api_call::<CreateTradeTicketResult>("create_trade_ticket", Some(arg_map))
            .await
            .map(|res| res.url)
    }

    /// Searches instruments by ticker or name and returns matching
    /// orderbookIds, tickers, and names. Use this tool before
    /// [`create_trade_ticket`](Self::create_trade_ticket) when multiple
    /// instruments have similar names.
    pub async fn search_instruments(
        &self,
        query: &str,
    ) -> Result<Vec<TradeInstrumentInfo>, ClientCallError> {
        let mut arg_map = serde_json::Map::new();
        arg_map.insert("query".to_string(), query.into());
        self.api_call("search_instruments", Some(arg_map)).await
    }

    /// Returns the authenticated user's watchlists with their ID, name, and the
    /// number of instruments on each list. Use [`get_watchlist`](Self::get_watchlist) with a listId to
    /// read the instruments on a specific watchlist.
    pub async fn get_watchlists(&self) -> Result<Vec<WatchlistInfo>, ClientCallError> {
        self.api_call("get_watchlists", None).await
    }

    /// Returns the instruments on a single watchlist, identified by listId.
    /// Each instrument is enriched with its orderbookId, ticker and name. Use
    /// [`get_watchlists`](Self::get_watchlists) first to discover valid listIds.
    pub async fn get_watchlist(&self, list_id: u64) -> Result<Watchlist, ClientCallError> {
        let mut arg_map = serde_json::Map::new();
        arg_map.insert("listId".to_string(), list_id.into());
        self.api_call("get_watchlist", Some(arg_map)).await
    }

    /// Creates a new watchlist with the given name for the authenticated user.
    /// If a watchlist with the same name already exists, returns that existing
    /// watchlist.
    #[doc(alias = "create_or_get_watchlist")]
    pub async fn create_watchlist(&self, name: &str) -> Result<WatchlistInfo, ClientCallError> {
        let mut arg_map = serde_json::Map::new();
        arg_map.insert("name".to_string(), name.into());
        self.api_call("create_watchlist", Some(arg_map)).await
    }

    /// Removes one or more instruments from a watchlist by orderbookId.
    /// OrderbookIds that are not on the watchlist are silently ignored.
    ///
    /// Returns all passed in orderbookIds - even those that were not found.
    pub async fn remove_from_watchlist(
        &self,
        list_id: u64,
        orderbook_ids: &[u64],
    ) -> Result<RemoveFromWatchlistResult, ClientCallError> {
        let mut arg_map = serde_json::Map::new();
        arg_map.insert("listId".to_string(), list_id.into());
        arg_map.insert("orderbookIds".to_string(), orderbook_ids.into());
        self.api_call("remove_from_watchlist", Some(arg_map)).await
    }

    /// Fetches and prints available tools and prompts from the server.
    /// Used for southesk development.
    ///
    /// # Panics
    /// Panics if writing to the result string fails.
    pub async fn introspect(&self) -> String {
        let mut result = String::new();
        writeln!(result, "Fetching available tools from server...").unwrap();

        match self.state.client.peer().list_all_tools().await {
            Ok(tools) => {
                writeln!(result, "Available tools: {}", tools.len()).unwrap();
                for tool in tools {
                    writeln!(
                        result,
                        "- {} ({})\n{:#?}\n{:#?}\n",
                        tool.name,
                        tool.description.unwrap_or_default(),
                        tool.input_schema,
                        tool.output_schema,
                    )
                    .unwrap();
                }
            }
            Err(e) => {
                writeln!(result, "Error fetching tools: {e}").unwrap();
            }
        }

        writeln!(result, "Fetching available prompts from server...").unwrap();

        match self.state.client.peer().list_all_prompts().await {
            Ok(prompts) => {
                writeln!(result, "Available prompts: {}", prompts.len()).unwrap();
                for prompt in prompts {
                    writeln!(result, "- {}", prompt.name).unwrap();
                }
            }
            Err(e) => {
                writeln!(result, "Error fetching prompts: {e}").unwrap();
            }
        }
        result
    }

    /// Calls the specified MCP tool with the given arguments.
    async fn api_call<T: DeserializeOwned>(
        &self,
        tool: &str,
        args: Option<JsonObject>,
    ) -> Result<T, ClientCallError> {
        let req = CallToolRequestParams::new(tool.to_owned());
        let req = if let Some(args) = args {
            req.with_arguments(args)
        } else {
            req
        };
        debug!("Call request: {:#?}", req);
        let res = self.state.client.call_tool(req).await?;
        debug!("Call response: {:#?}", res);
        parse_result::<T>(&res)
    }
}

fn parse_result<T: DeserializeOwned>(res: &CallToolResult) -> Result<T, ClientCallError> {
    let text = &res
        .content
        .first()
        .ok_or(ClientCallError::parse_err("No content element in response"))?
        .raw
        .as_text()
        .ok_or(ClientCallError::parse_err("No raw text in response"))?
        .text;
    if res.is_error.unwrap_or(false) {
        return Err(ClientCallError::McpError(format!(
            "Error from server: {text}"
        )));
    }
    serde_json::from_str::<T>(text).map_err(|e| ClientCallError::ParseError {
        msg: format!("Failed to parse response text: {text}"),
        source: Some(e.into()),
    })
}