1#![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#[derive(Clone)]
27#[non_exhaustive]
28pub struct BezantMcp {
29 pub client: bezant::Client,
31 pub cache: Arc<bezant::SymbolCache>,
33 pub tool_router: ToolRouter<BezantMcp>,
35}
36
37impl BezantMcp {
38 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#[derive(Debug, Deserialize, schemars::JsonSchema)]
52pub struct AccountIdArgs {
53 pub account_id: String,
55}
56
57#[derive(Debug, Deserialize, schemars::JsonSchema)]
59pub struct SymbolArgs {
60 pub symbol: String,
62}
63
64#[tool_router]
67impl BezantMcp {
68 #[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 #[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 #[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 #[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 #[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 #[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 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}