1use crate::handlers;
4use crate::tool_args::{
5 NoArgs, QuerySubscriptionReq, SubAccPushReq, SubscribeReq, UnsubAccPushReq, UnsubscribeReq,
6};
7use crate::tool_auth::CallerSnapshot;
8use rmcp::{
9 RoleServer, handler::server::wrapper::Parameters, service::RequestContext, tool, tool_router,
10};
11
12use super::{FutuServer, system::json_tool_output};
13
14#[tool_router(router = subscription_tool_router, vis = "pub(crate)")]
15impl FutuServer {
16 #[tool(
17 description = "Query current subscription state (subscribed types, quota used/remaining). Python SDK: OpenQuoteContext.query_subscription."
18 )]
19 async fn futu_query_subscription(
20 &self,
21 Parameters(req): Parameters<QuerySubscriptionReq>,
22 req_ctx: RequestContext<RoleServer>,
23 ) -> std::result::Result<String, String> {
24 tracing::info!(tool = "futu_query_subscription");
25 let client = self
26 .read_client_or_err("futu_query_subscription", &req_ctx, None, None)
27 .await?;
28 Self::wrap_result(handlers::core::query_subscription(&client, req.is_req_all_conn).await)
29 }
30
31 #[tool(
32 description = "Get current daemon used quota counters: subscribed quote quota and historical K-line quota. Python SDK: OpenQuoteContext.get_used_quota."
33 )]
34 async fn futu_get_used_quota(
35 &self,
36 Parameters(_req): Parameters<NoArgs>,
37 req_ctx: RequestContext<RoleServer>,
38 ) -> std::result::Result<String, String> {
39 tracing::info!(tool = "futu_get_used_quota");
40 let client = self
41 .read_client_or_err("futu_get_used_quota", &req_ctx, None, None)
42 .await?;
43 Self::wrap_result(handlers::core::get_used_quota(&client).await)
44 }
45
46 #[tool(
58 description = "Subscribe market data for given symbols + sub_types. Push data arrives via SSE notifications (HTTP mode). Python SDK: OpenQuoteContext.subscribe."
59 )]
60 async fn futu_subscribe(
61 &self,
62 Parameters(req): Parameters<SubscribeReq>,
63 req_ctx: RequestContext<RoleServer>,
64 ) -> std::result::Result<String, String> {
65 req.validate()?;
66 tracing::info!(
67 tool = "futu_subscribe",
68 symbols = ?req.symbols,
69 sub_types = ?req.sub_types,
70 is_first_push = req.is_first_push,
71 is_reg_push = req.is_reg_push,
72 extended_time = ?req.extended_time,
73 session = ?req.session,
74 is_sub_order_book_detail = ?req.is_sub_order_book_detail,
75 );
76 let client = self
77 .read_client_or_err("futu_subscribe", &req_ctx, None, None)
78 .await?;
79 Self::wrap_result(
80 handlers::core::subscribe(
81 &client,
82 &req.symbols,
83 &req.sub_types,
84 req.is_first_push,
85 req.is_reg_push,
86 req.extended_time,
87 req.session,
88 req.is_sub_order_book_detail,
89 )
90 .await,
91 )
92 }
93
94 #[tool(
95 description = "Unsubscribe market data (by symbol+type, or unsub_all to clear this connection). Python SDK: OpenQuoteContext.unsubscribe / unsubscribe_all."
96 )]
97 async fn futu_unsubscribe(
98 &self,
99 Parameters(req): Parameters<UnsubscribeReq>,
100 req_ctx: RequestContext<RoleServer>,
101 ) -> std::result::Result<String, String> {
102 tracing::info!(
103 tool = "futu_unsubscribe",
104 count = req.symbols.len(),
105 unsub_all = req.unsub_all
106 );
107 let client = self
108 .read_client_or_err("futu_unsubscribe", &req_ctx, None, None)
109 .await?;
110 Self::wrap_result(
111 handlers::core::unsubscribe(&client, &req.symbols, &req.sub_types, req.unsub_all).await,
112 )
113 }
114
115 #[tool(
116 description = "Subscribe account order / deal push for given trading accounts. HTTP-mode MCP clients receive pushes as LoggingMessage notifications with {kind, proto_id, body_base64}. Payload body is raw Futu protobuf; decode client-side. Python SDK: OpenTradeContext.sub_acc_push."
117 )]
118 async fn futu_sub_acc_push(
119 &self,
120 Parameters(req): Parameters<SubAccPushReq>,
121 req_ctx: RequestContext<RoleServer>,
122 ) -> std::result::Result<String, String> {
123 if req.acc_ids.is_empty() {
128 return Err(
129 "futu_sub_acc_push: acc_ids 必填非空. 之前空 list silent 全订阅 \
130 (v1.4.102 codex 47 F2 P1 fix). 调 futu_list_accounts 看可用 \
131 acc_id, 显式列出要订阅的账户."
132 .to_string(),
133 );
134 }
135
136 let mut snap_opt: Option<CallerSnapshot> = None;
147 for (idx, acc_id) in req.acc_ids.iter().enumerate() {
148 let snap = self.require_acc_read_with_acc_id(
149 "futu_sub_acc_push",
150 &req_ctx,
151 req.api_key.as_deref(),
152 Some(*acc_id),
153 )?;
154 if idx == 0 {
155 snap_opt = Some(snap);
156 }
157 }
158 let Some(snap) = snap_opt else {
159 return Err(serde_json::json!({
160 "error": "futu_sub_acc_push: caller snapshot resolution failed after acc_ids validation",
161 "status": "error",
162 "hint": "retry after refreshing API key state; if it persists, check auth pipeline logs",
163 })
164 .to_string());
165 };
166
167 tracing::info!(tool = "futu_sub_acc_push", count = req.acc_ids.len());
168
169 let client = self.client_or_err().await?;
170
171 let daemon_resp = handlers::trade::sub_acc_push(&client, &req.acc_ids).await;
177 match daemon_resp {
178 Ok(_) => {
179 let owner_key_id = snap.key_id.clone();
184 let bearer_token = snap.bearer_token.clone();
185 let acc_ids_set: std::collections::HashSet<u64> =
186 req.acc_ids.iter().copied().collect();
187 let session_id = self
188 .state
189 .register_push_subscriber_with_owner(
190 req_ctx.peer.clone(),
191 acc_ids_set,
192 bearer_token,
193 owner_key_id,
194 )
195 .await;
196 tracing::info!(
197 tool = "futu_sub_acc_push",
198 session_id = %session_id,
199 count = req.acc_ids.len(),
200 "v1.4.102 audit 47 F3: push subscriber registered after daemon success"
201 );
202 json_tool_output(
203 "futu_sub_acc_push",
204 &serde_json::json!({
205 "ok": true,
206 "subscribed_acc_ids": req.acc_ids,
207 "session_id": session_id,
208 "unsub_hint": format!(
209 "call `futu_unsub_acc_push` with session_id=\"{session_id}\" to stop receiving pushes; otherwise auto-purged after 4h"
210 ),
211 }),
212 )
213 }
214 Err(e) => Self::tool_err(format!("futu_sub_acc_push: {e}")),
215 }
216 }
217
218 #[tool(
219 description = "Unsubscribe from account push notifications. Pass session_id from previous futu_sub_acc_push response (session_id field or unsub_hint). Returns {removed_count}. If session_id is not found, removed_count=0 (likely auto-purged or never registered)."
220 )]
221 async fn futu_unsub_acc_push(
222 &self,
223 Parameters(req): Parameters<UnsubAccPushReq>,
224 req_ctx: RequestContext<RoleServer>,
225 ) -> std::result::Result<String, String> {
226 let snap = self.require_acc_read_with_acc_id(
230 "futu_unsub_acc_push",
231 &req_ctx,
232 req.api_key.as_deref(),
233 None,
234 )?;
235 let Some(ref session_id) = req.session_id else {
236 return Self::tool_err(
237 "session_id required. Get it from a previous futu_sub_acc_push response.",
238 );
239 };
240 let caller_key_id = snap.key_id;
249
250 let removed = self
251 .state
252 .unregister_push_subscriber_with_owner_check(
253 session_id.as_str(),
254 caller_key_id.as_deref(),
255 )
256 .await;
257 tracing::info!(
258 tool = "futu_unsub_acc_push",
259 removed = removed.is_ok(),
260 session_id = %session_id,
261 caller_key_id = ?caller_key_id,
262 "v1.4.103 B8: push subscriber unregister with ownership check"
263 );
264 match removed {
265 Ok(true) => json_tool_output("futu_unsub_acc_push", &serde_json::json!({
266 "ok": true,
267 "removed_count": 1,
268 "session_id": session_id,
269 })),
270 Ok(false) => json_tool_output("futu_unsub_acc_push", &serde_json::json!({
271 "ok": true,
272 "removed_count": 0,
273 "session_id": session_id,
274 "hint": "session_id not found (likely 4h auto-purged or never registered)",
275 })),
276 Err(reason) => Err(serde_json::json!({
277 "error": format!("ownership check failed: {reason}"),
278 "status": "error",
279 "hint": "only the original caller (matched by API key id) or an admin-scope key can unsub a session.",
280 })
281 .to_string()),
282 }
283 }
284
285 #[tool(
286 description = "Diagnostic — list active push subscriptions on this MCP server. Returns {total_count, subscriptions: [{session_id, acc_ids, age_secs}]}. Useful to verify whether futu_sub_acc_push registered, check auto-purge timing, or debug missing pushes."
287 )]
288 async fn futu_push_subscriber_info(
289 &self,
290 Parameters(_req): Parameters<NoArgs>,
291 req_ctx: RequestContext<RoleServer>,
292 ) -> std::result::Result<String, String> {
293 let snap =
302 self.require_acc_read_with_acc_id("futu_push_subscriber_info", &req_ctx, None, None)?;
303 let subs = self
304 .state
305 .push_subscribers_summary(snap.allowed_acc_ids.as_ref())
306 .await;
307 json_tool_output(
308 "futu_push_subscriber_info",
309 &serde_json::json!({
310 "ok": true,
311 "total_count": subs.len(),
312 "subscriptions": subs.iter().map(|(sid, acc_ids, age)| serde_json::json!({
313 "session_id": sid,
314 "acc_ids": acc_ids.iter().collect::<Vec<_>>(),
315 "age_secs": age,
316 })).collect::<Vec<_>>(),
317 }),
318 )
319 }
320}