1use crate::handlers;
4use crate::tool_args::{
5 NoArgs, QuerySubscriptionReq, SubAccPushReq, SubscribeReq, UnsubAccPushReq, UnsubscribeReq,
6};
7use crate::tool_auth::CallerSnapshot;
8use rmcp::{RoleServer, handler::server::wrapper::Parameters, service::RequestContext};
9
10use super::{FutuServer, system::json_tool_output};
11
12impl FutuServer {
13 async fn futu_query_subscription_impl(
14 &self,
15 Parameters(req): Parameters<QuerySubscriptionReq>,
16 req_ctx: RequestContext<RoleServer>,
17 ) -> std::result::Result<String, String> {
18 tracing::info!(tool = "futu_query_subscription");
19 let client = self
20 .read_client_or_err("futu_query_subscription", &req_ctx, None, None)
21 .await?;
22 Self::wrap_result(handlers::core::query_subscription(&client, req.is_req_all_conn).await)
23 }
24
25 async fn futu_get_used_quota_impl(
26 &self,
27 Parameters(_req): Parameters<NoArgs>,
28 req_ctx: RequestContext<RoleServer>,
29 ) -> std::result::Result<String, String> {
30 tracing::info!(tool = "futu_get_used_quota");
31 let client = self
32 .read_client_or_err("futu_get_used_quota", &req_ctx, None, None)
33 .await?;
34 Self::wrap_result(handlers::core::get_used_quota(&client).await)
35 }
36
37 async fn futu_subscribe_impl(
49 &self,
50 Parameters(req): Parameters<SubscribeReq>,
51 req_ctx: RequestContext<RoleServer>,
52 ) -> std::result::Result<String, String> {
53 req.validate()?;
54 tracing::info!(
55 tool = "futu_subscribe",
56 symbols = ?req.symbols,
57 sub_types = ?req.sub_types,
58 is_first_push = req.is_first_push,
59 is_reg_push = req.is_reg_push,
60 extended_time = ?req.extended_time,
61 session = ?req.session,
62 is_sub_order_book_detail = ?req.is_sub_order_book_detail,
63 );
64 let client = self
65 .read_client_or_err("futu_subscribe", &req_ctx, None, None)
66 .await?;
67 Self::wrap_result(
68 handlers::core::subscribe(
69 &client,
70 &req.symbols,
71 &req.sub_types,
72 req.is_first_push,
73 req.is_reg_push,
74 req.extended_time,
75 req.session,
76 req.is_sub_order_book_detail,
77 )
78 .await,
79 )
80 }
81
82 async fn futu_unsubscribe_impl(
83 &self,
84 Parameters(req): Parameters<UnsubscribeReq>,
85 req_ctx: RequestContext<RoleServer>,
86 ) -> std::result::Result<String, String> {
87 tracing::info!(
88 tool = "futu_unsubscribe",
89 count = req.symbols.len(),
90 unsub_all = req.unsub_all
91 );
92 let client = self
93 .read_client_or_err("futu_unsubscribe", &req_ctx, None, None)
94 .await?;
95 Self::wrap_result(
96 handlers::core::unsubscribe(&client, &req.symbols, &req.sub_types, req.unsub_all).await,
97 )
98 }
99
100 async fn futu_sub_acc_push_impl(
101 &self,
102 Parameters(req): Parameters<SubAccPushReq>,
103 req_ctx: RequestContext<RoleServer>,
104 ) -> std::result::Result<String, String> {
105 if req.acc_ids.is_empty() {
110 return Err(
111 "futu_sub_acc_push: acc_ids 必填非空. 之前空 list silent 全订阅 \
112 (v1.4.102 codex 47 F2 P1 fix). 调 futu_list_accounts 看可用 \
113 acc_id, 显式列出要订阅的账户."
114 .to_string(),
115 );
116 }
117
118 let mut snap_opt: Option<CallerSnapshot> = None;
129 for (idx, acc_id) in req.acc_ids.iter().enumerate() {
130 let snap = self.require_acc_read_with_acc_id(
131 "futu_sub_acc_push",
132 &req_ctx,
133 req.api_key.as_deref(),
134 Some(*acc_id),
135 )?;
136 if idx == 0 {
137 snap_opt = Some(snap);
138 }
139 }
140 let Some(snap) = snap_opt else {
141 return Err(serde_json::json!({
142 "error": "futu_sub_acc_push: caller snapshot resolution failed after acc_ids validation",
143 "status": "error",
144 "hint": "retry after refreshing API key state; if it persists, check auth pipeline logs",
145 })
146 .to_string());
147 };
148
149 let is_modern =
150 req_ctx.protocol_version() == Some(rmcp::model::ProtocolVersion::V_2026_07_28);
151 let registration_snapshot = if is_modern {
152 let continuation = self.require_same_push_continuation_identity(&req_ctx, &snap)?;
153 if let Some(allowed) = continuation
154 .allowed_acc_ids
155 .as_ref()
156 .filter(|allowed| !allowed.is_empty())
157 && let Some(disallowed) =
158 req.acc_ids.iter().find(|acc_id| !allowed.contains(acc_id))
159 {
160 return Self::tool_err(format!(
161 "futu_sub_acc_push: account {disallowed} is outside the current continuation identity scope"
162 ));
163 }
164 continuation
165 } else {
166 snap
167 };
168 let modern_permit = if is_modern {
172 Some(self.state.reserve_modern_push_handle().await?)
173 } else {
174 None
175 };
176
177 tracing::info!(tool = "futu_sub_acc_push", count = req.acc_ids.len());
178
179 let client = self.client_or_err().await?;
180
181 let daemon_resp = handlers::trade::sub_acc_push(&client, &req.acc_ids).await;
187 match daemon_resp {
188 Ok(_) => {
189 let owner_key_id = registration_snapshot.key_id.clone();
194 let allowed_acc_ids = registration_snapshot.allowed_acc_ids.clone();
195 let allowed_markets = registration_snapshot
196 .rec
197 .as_ref()
198 .and_then(|rec| rec.allowed_markets.clone());
199 let acc_ids_set: std::collections::HashSet<u64> =
200 req.acc_ids.iter().copied().collect();
201 let delivery = match modern_permit {
202 Some(permit) => crate::state::PushDeliveryTarget::ModernResource(permit),
203 None => self.legacy_push_delivery(req_ctx.peer.clone()),
204 };
205 let session_id = if is_modern {
206 self.state
207 .register_push_subscriber_with_owner(
208 delivery,
209 acc_ids_set,
210 owner_key_id,
211 allowed_acc_ids,
212 allowed_markets,
213 None,
214 )
215 .await?
216 } else {
217 self.register_legacy_push_subscriber(
218 delivery,
219 acc_ids_set,
220 owner_key_id,
221 allowed_acc_ids,
222 allowed_markets,
223 )
224 .await?
225 };
226 tracing::info!(
227 tool = "futu_sub_acc_push",
228 session_id = %session_id,
229 count = req.acc_ids.len(),
230 "v1.4.102 audit 47 F3: push subscriber registered after daemon success"
231 );
232 let mut output = serde_json::json!({
233 "ok": true,
234 "subscribed_acc_ids": req.acc_ids,
235 "session_id": session_id,
236 "unsub_hint": format!(
237 "call `futu_unsub_acc_push` with session_id=\"{session_id}\" to stop receiving pushes; otherwise auto-purged after 4h"
238 ),
239 });
240 if is_modern {
241 output["resource_uri"] =
242 serde_json::Value::String(crate::state::push_resource_uri(&session_id));
243 output["listen_hint"] = serde_json::Value::String(
244 "call subscriptions/listen with exactly this resource URI; use resources/read to atomically drain queued events"
245 .to_string(),
246 );
247 }
248 json_tool_output("futu_sub_acc_push", &output)
249 }
250 Err(e) => Self::tool_err(format!("futu_sub_acc_push: {e}")),
251 }
252 }
253
254 async fn futu_unsub_acc_push_impl(
255 &self,
256 Parameters(req): Parameters<UnsubAccPushReq>,
257 req_ctx: RequestContext<RoleServer>,
258 ) -> std::result::Result<String, String> {
259 let snap = self.require_acc_read_with_acc_id(
263 "futu_unsub_acc_push",
264 &req_ctx,
265 req.api_key.as_deref(),
266 None,
267 )?;
268 let Some(ref session_id) = req.session_id else {
269 return Self::tool_err(
270 "session_id required. Get it from a previous futu_sub_acc_push response.",
271 );
272 };
273 let caller_key_id =
282 if req_ctx.protocol_version() == Some(rmcp::model::ProtocolVersion::V_2026_07_28) {
283 self.require_same_push_continuation_identity(&req_ctx, &snap)?
284 .key_id
285 } else {
286 snap.key_id
287 };
288
289 let removed = self
290 .state
291 .unregister_push_subscriber_with_owner_check(
292 session_id.as_str(),
293 caller_key_id.as_deref(),
294 )
295 .await;
296 tracing::info!(
297 tool = "futu_unsub_acc_push",
298 removed = removed.is_ok(),
299 session_id = %session_id,
300 caller_key_id = ?caller_key_id,
301 "v1.4.103 B8: push subscriber unregister with ownership check"
302 );
303 match removed {
304 Ok(true) => json_tool_output("futu_unsub_acc_push", &serde_json::json!({
305 "ok": true,
306 "removed_count": 1,
307 "session_id": session_id,
308 })),
309 Ok(false) if req_ctx.protocol_version()
310 == Some(rmcp::model::ProtocolVersion::V_2026_07_28) =>
311 {
312 Self::tool_err("push resource not found for current caller")
313 }
314 Ok(false) => json_tool_output("futu_unsub_acc_push", &serde_json::json!({
315 "ok": true,
316 "removed_count": 0,
317 "session_id": session_id,
318 "hint": "session_id not found (likely 4h auto-purged or never registered)",
319 })),
320 Err(reason) => Err(serde_json::json!({
321 "error": format!("ownership check failed: {reason}"),
322 "status": "error",
323 "hint": "only the original caller (matched by API key id) or an admin-scope key can unsub a session.",
324 })
325 .to_string()),
326 }
327 }
328
329 async fn futu_push_subscriber_info_impl(
330 &self,
331 Parameters(_req): Parameters<NoArgs>,
332 req_ctx: RequestContext<RoleServer>,
333 ) -> std::result::Result<String, String> {
334 let snap =
343 self.require_acc_read_with_acc_id("futu_push_subscriber_info", &req_ctx, None, None)?;
344 let subs = self
345 .state
346 .push_subscribers_summary(snap.allowed_acc_ids.as_ref())
347 .await;
348 json_tool_output(
349 "futu_push_subscriber_info",
350 &serde_json::json!({
351 "ok": true,
352 "total_count": subs.len(),
353 "subscriptions": subs.iter().map(|(sid, acc_ids, age)| serde_json::json!({
354 "session_id": sid,
355 "acc_ids": acc_ids.iter().collect::<Vec<_>>(),
356 "age_secs": age,
357 })).collect::<Vec<_>>(),
358 }),
359 )
360 }
361}
362
363include!(concat!(
364 env!("OUT_DIR"),
365 "/generated_mcp_routes_subscription.rs"
366));