Skip to main content

bezant_mcp/
lib.rs

1//! Library half of `bezant-mcp` — the `BezantMcp` struct, its tool router,
2//! and the `ServerHandler` impl. Lives here so integration tests can
3//! exercise the tools via an in-memory MCP transport without shelling out
4//! to the built binary.
5
6#![warn(missing_docs)]
7
8use std::sync::Arc;
9
10use rmcp::handler::server::router::tool::ToolRouter;
11use rmcp::handler::server::wrapper::Parameters;
12use rmcp::model::{CallToolResult, Content, ServerCapabilities, ServerInfo};
13use rmcp::{schemars, tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler};
14use serde::Deserialize;
15use serde_json::Value;
16
17/// MCP server exposing the Bezant IBKR client as tools.
18///
19/// Built by [`BezantMcp::new`] around a prepared [`bezant::Client`] and a
20/// shared [`bezant::SymbolCache`]. Implements [`rmcp::ServerHandler`] so
21/// it plugs straight into rmcp's stdio / streamable transports.
22///
23/// Marked `#[non_exhaustive]` so extra private fields can be added in a
24/// point release without breaking downstream construction — callers
25/// should always go through [`BezantMcp::new`].
26#[derive(Clone)]
27#[non_exhaustive]
28pub struct BezantMcp {
29    /// Typed Bezant client used to call CPAPI endpoints.
30    pub client: bezant::Client,
31    /// Shared ticker → conid cache reused across tool invocations.
32    pub cache: Arc<bezant::SymbolCache>,
33    /// Auto-generated tool router produced by [`rmcp::tool_router`].
34    pub tool_router: ToolRouter<BezantMcp>,
35}
36
37impl BezantMcp {
38    /// Construct a handler around a prepared [`bezant::Client`] and cache.
39    pub fn new(client: bezant::Client, cache: Arc<bezant::SymbolCache>) -> Self {
40        Self {
41            client,
42            cache,
43            tool_router: BezantMcp::tool_router(),
44        }
45    }
46}
47
48// ---------- Tool argument types ----------
49
50/// Args for tools scoped to a single IBKR account.
51#[derive(Debug, Deserialize, schemars::JsonSchema)]
52pub struct AccountIdArgs {
53    /// IBKR account identifier (e.g. `DU123456` for paper, `U123456` for live).
54    pub account_id: String,
55}
56
57/// Args for tools that look up data by ticker symbol.
58#[derive(Debug, Deserialize, schemars::JsonSchema)]
59pub struct SymbolArgs {
60    /// Ticker symbol, e.g. `AAPL`, `BRK B`, `GLD`.
61    pub symbol: String,
62}
63
64// ---------- Tool implementations ----------
65
66#[tool_router]
67impl BezantMcp {
68    /// Report Gateway session status.
69    #[tool(
70        description = "Check whether the IBKR Client Portal Gateway is authenticated and connected. Returns the authenticated/connected/competing flags and any message. Always call this first if other tools start returning 'not authenticated' errors."
71    )]
72    pub async fn health(&self) -> Result<CallToolResult, McpError> {
73        let status = self
74            .client
75            .auth_status()
76            .await
77            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
78        Ok(json_result(serde_json::json!({
79            "authenticated": status.authenticated,
80            "connected": status.connected,
81            "competing": status.competing,
82            "message": status.message,
83        })))
84    }
85
86    /// List every account.
87    #[tool(
88        description = "List every IBKR account the Gateway is aware of. Call this to discover account IDs before asking about positions or summary."
89    )]
90    pub async fn list_accounts(&self) -> Result<CallToolResult, McpError> {
91        let raw = self
92            .fetch_json(&["portfolio", "accounts"])
93            .await
94            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
95        Ok(json_result(raw))
96    }
97
98    /// Portfolio summary (NAV, cash, buying power).
99    #[tool(
100        description = "Get the portfolio summary for one account: net liquidation value, total cash, buying power, margin details, etc."
101    )]
102    pub async fn account_summary(
103        &self,
104        Parameters(args): Parameters<AccountIdArgs>,
105    ) -> Result<CallToolResult, McpError> {
106        let raw = self
107            .fetch_json(&["portfolio", args.account_id.as_str(), "summary"])
108            .await
109            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
110        Ok(json_result(raw))
111    }
112
113    /// All positions (paginated).
114    #[tool(
115        description = "Return every open position for one account. Pagination is handled automatically."
116    )]
117    pub async fn positions(
118        &self,
119        Parameters(args): Parameters<AccountIdArgs>,
120    ) -> Result<CallToolResult, McpError> {
121        let mut all = Vec::new();
122        for page in 0..100u32 {
123            let page_str = page.to_string();
124            let raw = self
125                .fetch_json(&[
126                    "portfolio",
127                    args.account_id.as_str(),
128                    "positions",
129                    &page_str,
130                ])
131                .await
132                .map_err(|e| McpError::internal_error(e.to_string(), None))?;
133            let arr = raw.as_array().cloned().unwrap_or_default();
134            let n = arr.len();
135            all.extend(arr);
136            if n < bezant::POSITIONS_PAGE_SIZE {
137                break;
138            }
139        }
140        Ok(json_result(Value::Array(all)))
141    }
142
143    /// Ticker → conid resolution, cached.
144    #[tool(
145        description = "Resolve a ticker symbol to IBKR's numeric contract id (conid). Needed before most market-data or order operations. Results are memoised across calls in this MCP session."
146    )]
147    pub async fn conid_for(
148        &self,
149        Parameters(args): Parameters<SymbolArgs>,
150    ) -> Result<CallToolResult, McpError> {
151        let conid = self
152            .cache
153            .conid_for(&args.symbol)
154            .await
155            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
156        Ok(json_result(serde_json::json!({
157            "symbol": args.symbol,
158            "conid": conid,
159        })))
160    }
161
162    /// Tickle / session keepalive.
163    #[tool(
164        description = "Send a tickle to keep the IBKR session alive. The server already keeps the session alive automatically; this tool is mainly useful if you want to extend it explicitly."
165    )]
166    pub async fn tickle(&self) -> Result<CallToolResult, McpError> {
167        let tickle = self
168            .client
169            .tickle()
170            .await
171            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
172        Ok(json_result(
173            serde_json::json!({ "session": tickle.session }),
174        ))
175    }
176}
177
178impl BezantMcp {
179    /// Fetch a JSON body by walking the Gateway's path segments.
180    async fn fetch_json(&self, segments: &[&str]) -> anyhow::Result<Value> {
181        let mut url = self.client.base_url().clone();
182        {
183            let mut segs = url
184                .path_segments_mut()
185                .map_err(|()| anyhow::anyhow!("base url cannot be a base"))?;
186            for s in segments {
187                segs.push(s);
188            }
189        }
190        let resp = self.client.http().get(url).send().await?;
191        let status = resp.status();
192        let bytes = resp.bytes().await?;
193        if !status.is_success() {
194            anyhow::bail!(
195                "HTTP {status}: {body}",
196                body = String::from_utf8_lossy(&bytes)
197            );
198        }
199        Ok(serde_json::from_slice(&bytes)?)
200    }
201}
202
203#[tool_handler]
204impl ServerHandler for BezantMcp {
205    fn get_info(&self) -> ServerInfo {
206        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
207    }
208}
209
210fn json_result(value: Value) -> CallToolResult {
211    let text = serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".into());
212    CallToolResult::success(vec![Content::text(text)])
213}