1use std::collections::HashMap;
4use std::fmt;
5use std::sync::Arc;
6use std::sync::atomic::Ordering;
7
8use anyhow::{Context, Result, anyhow};
9use futu_auth::{KeyRecord, KeyStore, RuntimeCounters};
10use futu_core::qot_symbol;
11use futu_net::client::{ClientConfig, FutuClient, ReconnectingClient};
12use futu_net::reconnect::ReconnectPolicy;
13use futu_qot::types::Security;
14use rmcp::{RoleServer, service::Peer};
15use tokio::sync::{Mutex, Semaphore};
16
17mod push_filter;
18mod push_subscribers;
19pub(crate) use push_subscribers::{LegacyPushServiceLease, PushDeliveryTarget};
20#[cfg(test)]
21pub(crate) use push_subscribers::{MAX_MODERN_PUSH_HANDLES, MODERN_PUSH_QUEUE_CAPACITY};
22pub(crate) use push_subscribers::{parse_push_resource_uri, push_resource_uri};
23#[cfg(test)]
24mod tests;
25
26use push_filter::{TradePushDecode, classify_trade_push, trd_market_int_to_str};
27#[cfg(test)]
28use push_filter::{
29 extract_acc_id_and_market_from_push, is_trade_push_proto_id, subscriber_should_receive,
30 subscriber_should_receive_with_market, subscriber_visible_to_caller,
31};
32use push_subscribers::{PushSubscriber, SubscriberDelivery};
33
34use crate::qot_sdk_adapter;
35
36const MCP_CONNECT_TOTAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
37const MCP_CONNECT_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(200);
38pub(crate) const LEGACY_PUSH_INFO_LEVEL_RANK: u8 = 1;
39
40struct RedactedAccountIdSet<'a> {
45 account_ids: &'a [u64],
46}
47
48impl<'a> RedactedAccountIdSet<'a> {
49 const fn new(account_ids: &'a [u64]) -> Self {
50 Self { account_ids }
51 }
52}
53
54impl fmt::Debug for RedactedAccountIdSet<'_> {
55 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56 write!(formatter, "accounts(count={})", self.account_ids.len())
57 }
58}
59
60struct RedactedMcpError<'a> {
63 _error: &'a anyhow::Error,
64}
65
66impl<'a> RedactedMcpError<'a> {
67 const fn new(error: &'a anyhow::Error) -> Self {
68 Self { _error: error }
69 }
70}
71
72impl fmt::Debug for RedactedMcpError<'_> {
73 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74 formatter.write_str("request_failed")
75 }
76}
77
78fn base64_encode_bytes(bytes: &[u8]) -> String {
80 use base64::Engine as _;
81 base64::engine::general_purpose::STANDARD.encode(bytes)
82}
83
84struct PendingPushDelivery {
85 target: SubscriberDelivery,
86 data: serde_json::Value,
87 session_id: String,
88 owner_key_id: Option<String>,
89 proto_id: u32,
90}
91
92fn push_delivery_is_authorized(
97 subscriber: &PushSubscriber,
98 key_store: &KeyStore,
99 filter_registry: &futu_auth_pipeline::FilterRegistry,
100 decode_result: &TradePushDecode,
101 event_type: &'static str,
102 push_acc_id: Option<u64>,
103 push_trd_market_str: Option<&'static str>,
104 proto_id: u32,
105) -> bool {
106 if event_type == "private_user"
107 && !private_user_push_owner_is_authorized(subscriber.owner_key_id.as_deref(), key_store)
108 {
109 return false;
110 }
111 let (allowed_acc_ids, allowed_markets) = match &subscriber.delivery {
112 SubscriberDelivery::LegacyPeer(_, _) => (
113 subscriber.allowed_acc_ids_snapshot.clone(),
114 subscriber.allowed_markets_snapshot.clone(),
115 ),
116 SubscriberDelivery::ModernResource(_) => {
117 let Some(owner) = subscriber.owner_key_id.as_deref() else {
118 return false;
119 };
120 let Some(current) = key_store
121 .get_by_id_for_current_machine(owner)
122 .filter(|record| {
123 !record.is_expired(chrono::Utc::now())
124 && record.scopes.contains(&futu_auth::Scope::AccRead)
125 })
126 else {
127 return false;
128 };
129 (
130 current.allowed_acc_ids.clone(),
131 current.allowed_markets.clone(),
132 )
133 }
134 };
135 if matches!(decode_result, TradePushDecode::DecodeFailed) {
136 let restricted = allowed_acc_ids
137 .as_ref()
138 .is_some_and(|allowed| !allowed.is_empty());
139 if restricted {
140 let key_id = subscriber.owner_key_id.as_deref().unwrap_or("<none>");
141 futu_auth::metrics::bump_ws_filtered("trade_decode_failed", key_id);
142 tracing::warn!(
143 proto_id,
144 key_id,
145 "MCP trade push body decode failed; dropped before legacy/modern delivery for restricted key"
146 );
147 return false;
148 }
149 }
150 let sub_state = (!subscriber.acc_ids.is_empty()).then_some(&subscriber.acc_ids);
151 let ctx = futu_auth_pipeline::PushEventCtx {
152 event_type,
153 event_acc: push_acc_id,
154 allowed_acc_ids: allowed_acc_ids.as_ref(),
155 sub_state,
156 event_trd_market: push_trd_market_str,
157 allowed_markets: allowed_markets.as_ref(),
158 };
159 if filter_registry.should_drop_event(&ctx) {
160 let key_id = subscriber.owner_key_id.as_deref().unwrap_or("<none>");
161 futu_auth::metrics::bump_ws_filtered("trade_market", key_id);
162 return false;
163 }
164 true
165}
166
167fn private_user_push_owner_is_authorized(owner_key_id: Option<&str>, key_store: &KeyStore) -> bool {
168 let Some(owner) = owner_key_id else {
169 return false;
170 };
171 key_store
172 .get_by_id_for_current_machine(owner)
173 .is_some_and(|record| {
174 !record.is_expired(chrono::Utc::now())
175 && record.scopes.contains(&futu_auth::Scope::AccRead)
176 })
177}
178
179#[allow(deprecated)]
180async fn notify_legacy_push(
181 peer: &Peer<RoleServer>,
182 minimum_level: &std::sync::atomic::AtomicU8,
183 data: serde_json::Value,
184) -> Result<(), rmcp::service::ServiceError> {
185 if minimum_level.load(Ordering::Acquire) > LEGACY_PUSH_INFO_LEVEL_RANK {
186 return Ok(());
187 }
188 let params =
189 rmcp::model::LoggingMessageNotificationParam::new(rmcp::model::LoggingLevel::Info, data)
190 .with_logger("futu_push");
191 peer.notify_logging_message(params).await
192}
193
194#[derive(Clone)]
196pub struct ServerState {
197 inner: Arc<Mutex<Inner>>,
199 enable_trading: bool,
202 allow_real_trading: bool,
204 key_store: Arc<KeyStore>,
206 authed_key: Option<Arc<KeyRecord>>,
208 opend_rest_url: Option<String>,
209 opend_api_key: Option<String>,
210 trade_pwd_account: Option<String>,
213 counters: Arc<RuntimeCounters>,
215 push_subscribers: Arc<Mutex<HashMap<String, PushSubscriber>>>,
220 modern_push_slots: Arc<Semaphore>,
226 connect_gate: Arc<Mutex<()>>,
229 purge_task_spawns: Arc<std::sync::atomic::AtomicU64>,
232 push_reregister_failures: Arc<std::sync::atomic::AtomicU64>,
235 push_registration_dirty: Arc<std::sync::atomic::AtomicBool>,
238}
239
240struct Inner {
242 gateway: String,
244 client: Option<Arc<FutuClient>>,
246 generation: u64,
248}
249
250impl ServerState {
251 pub fn new(gateway: String) -> Self {
254 Self {
255 inner: Arc::new(Mutex::new(Inner {
256 gateway,
257 client: None,
258 generation: 0,
259 })),
260 enable_trading: false,
261 allow_real_trading: false,
262 key_store: Arc::new(KeyStore::empty()),
263 authed_key: None,
264 opend_rest_url: None,
265 opend_api_key: None,
266 trade_pwd_account: None,
267 counters: Arc::new(RuntimeCounters::new()),
268 push_subscribers: Arc::new(Mutex::new(HashMap::new())),
269 modern_push_slots: Arc::new(Semaphore::new(push_subscribers::MAX_MODERN_PUSH_HANDLES)),
270 connect_gate: Arc::new(Mutex::new(())),
271 purge_task_spawns: Arc::new(std::sync::atomic::AtomicU64::new(0)),
272 push_reregister_failures: Arc::new(std::sync::atomic::AtomicU64::new(0)),
273 push_registration_dirty: Arc::new(std::sync::atomic::AtomicBool::new(false)),
274 }
275 }
276
277 #[cfg(test)]
279 pub(crate) async fn client_generation_for_test(&self) -> u64 {
280 self.inner.lock().await.generation
281 }
282
283 #[cfg(test)]
285 pub(crate) fn purge_task_spawn_count_for_test(&self) -> u64 {
286 self.purge_task_spawns.load(Ordering::Acquire)
287 }
288
289 pub fn with_trading(mut self, enable_trading: bool, allow_real_trading: bool) -> Self {
291 self.enable_trading = enable_trading;
292 self.allow_real_trading = allow_real_trading;
293 self
294 }
295
296 pub fn with_key_store(mut self, store: Arc<KeyStore>) -> Self {
298 self.key_store = store;
299 self
300 }
301
302 pub fn with_authed_key(mut self, key: Option<Arc<KeyRecord>>) -> Self {
304 self.authed_key = key;
305 self
306 }
307
308 pub fn with_opend_rest(mut self, base_url: Option<String>, api_key: Option<String>) -> Self {
309 self.opend_rest_url = base_url.map(|value| value.trim_end_matches('/').to_string());
310 self.opend_api_key = api_key.filter(|value| !value.is_empty());
311 self
312 }
313
314 pub fn opend_rest_url(&self) -> Option<&str> {
315 self.opend_rest_url.as_deref()
316 }
317
318 pub fn opend_api_key(&self) -> Option<&str> {
319 self.opend_api_key.as_deref()
320 }
321
322 pub fn with_trade_pwd_account(mut self, account: Option<String>) -> Self {
325 self.trade_pwd_account = account;
326 self
327 }
328
329 pub fn is_scope_mode(&self) -> bool {
331 self.key_store.is_configured()
332 }
333
334 pub fn enable_trading(&self) -> bool {
336 self.enable_trading
337 }
338
339 pub fn allow_real_trading(&self) -> bool {
341 self.allow_real_trading
342 }
343
344 pub fn key_store(&self) -> &Arc<KeyStore> {
346 &self.key_store
347 }
348
349 pub fn authed_key(&self) -> Option<Arc<KeyRecord>> {
351 self.authed_key.clone()
352 }
353
354 pub fn trade_pwd_account(&self) -> Option<&str> {
356 self.trade_pwd_account.as_deref()
357 }
358
359 pub fn counters(&self) -> &Arc<RuntimeCounters> {
361 &self.counters
362 }
363
364 pub async fn gateway(&self) -> String {
366 self.inner.lock().await.gateway.clone()
367 }
368
369 pub async fn client(&self) -> Result<Arc<FutuClient>> {
376 let (gateway, stale) = {
377 let guard = self.inner.lock().await;
378 match &guard.client {
379 Some(c) if !c.is_closed() => {
380 let alive = c.clone();
381 drop(guard);
382 if self
385 .push_registration_dirty
386 .swap(false, std::sync::atomic::Ordering::AcqRel)
387 {
388 self.restore_push_registration(&alive).await;
389 }
390 return Ok(alive);
391 }
392 cached => (guard.gateway.clone(), cached.clone()),
393 }
394 };
395
396 let _connect_gate = self.connect_gate.lock().await;
401 {
402 let guard = self.inner.lock().await;
403 if let Some(c) = &guard.client
404 && !c.is_closed()
405 {
406 return Ok(c.clone());
407 }
408 }
409
410 let config = ClientConfig {
411 addr: gateway.clone(),
412 client_ver: env!("CARGO_PKG_VERSION").to_string(),
413 client_id: "futu-mcp".to_string(),
414 recv_notify: false,
415 rsa_key: None,
416 };
417 let policy =
418 ReconnectPolicy::new(MCP_CONNECT_RETRY_DELAY, MCP_CONNECT_RETRY_DELAY, Some(1));
419 let mut reconnector = ReconnectingClient::new(config).with_policy(policy);
420 let connect_result =
421 tokio::time::timeout(MCP_CONNECT_TOTAL_TIMEOUT, reconnector.connect()).await;
422 let (client, mut push_rx, info) = match connect_result {
423 Ok(result) => {
424 result.with_context(|| format!("connect to futu gateway at {gateway}"))?
425 }
426 Err(_) => {
427 return Err(anyhow!(
428 "connect to futu gateway at {gateway} timed out after {}s",
429 MCP_CONNECT_TOTAL_TIMEOUT.as_secs()
430 ));
431 }
432 };
433
434 let arc = Arc::new(client);
435 let (generation, previous_conn_id) = {
436 let mut guard = self.inner.lock().await;
437 if let Some(existing) = &guard.client
441 && !existing.is_closed()
442 && !stale.as_ref().is_some_and(|s| Arc::ptr_eq(s, existing))
443 {
444 return Ok(existing.clone());
445 }
446 let previous_conn_id = guard.client.as_ref().and_then(|c| c.conn_id());
447 guard.client = Some(arc.clone());
448 guard.generation += 1;
449 (guard.generation, previous_conn_id)
450 };
451 if generation > 1 {
452 tracing::info!(
453 generation,
454 old_conn_id = previous_conn_id.unwrap_or(0),
455 new_conn_id = info.conn_id,
456 "mcp gateway client reconnected"
457 );
458 }
459
460 let subs_for_push = Arc::downgrade(&self.push_subscribers);
472 let key_store_for_push = Arc::downgrade(&self.key_store);
473 let filter_registry =
479 std::sync::Arc::new(futu_auth_pipeline::FilterRegistry::with_defaults());
480 tokio::spawn(async move {
481 while let Some(push) = push_rx.recv().await {
482 let Some(subs_for_push) = subs_for_push.upgrade() else {
483 break;
484 };
485 let Some(key_store_for_push) = key_store_for_push.upgrade() else {
486 break;
487 };
488 let subscribers = {
489 let subs = subs_for_push.lock().await;
490 if subs.is_empty() {
491 Vec::new()
492 } else {
493 subs.iter()
494 .map(|(session_id, sub)| (session_id.clone(), sub.clone()))
495 .collect::<Vec<_>>()
496 }
497 };
498 if subscribers.is_empty() {
499 continue; }
501 let decode_result = classify_trade_push(push.proto_id, &push.body);
507 let (push_acc_id, push_trd_market, decode_status, event_type) = match &decode_result
508 {
509 TradePushDecode::NotTrade
510 if push.proto_id == futu_core::proto_id::QOT_UPDATE_STOCK_NOTE =>
511 {
512 (None, None, "ok", "private_user")
513 }
514 TradePushDecode::NotTrade => (None, None, "ok", "quote"),
515 TradePushDecode::Decoded { acc_id, trd_market } => {
516 (Some(*acc_id), Some(*trd_market), "ok", "trade")
517 }
518 TradePushDecode::DecodeFailed => (None, None, "failed", "trade"),
519 };
520 let push_trd_market_str = push_trd_market.map(trd_market_int_to_str);
521 let payload = serde_json::json!({
525 "kind": "futu_push",
526 "proto_id": push.proto_id,
527 "acc_id": push_acc_id,
528 "event_type": event_type,
529 "trd_market": push_trd_market_str,
530 "decode_status": decode_status,
531 "body_base64": base64_encode_bytes(&push.body),
532 });
533 let deliveries = {
534 let mut deliveries = Vec::with_capacity(subscribers.len());
535 for (session_id, sub) in subscribers.iter() {
536 if !push_delivery_is_authorized(
537 sub,
538 &key_store_for_push,
539 &filter_registry,
540 &decode_result,
541 event_type,
542 push_acc_id,
543 push_trd_market_str,
544 push.proto_id,
545 ) {
546 continue;
547 }
548 deliveries.push(PendingPushDelivery {
549 target: sub.delivery.clone(),
550 data: payload.clone(),
551 session_id: session_id.clone(),
552 owner_key_id: sub.owner_key_id.clone(),
553 proto_id: push.proto_id,
554 });
555 }
556 deliveries
557 };
558 for delivery in deliveries {
559 match delivery.target {
560 SubscriberDelivery::LegacyPeer(peer, minimum_level) => {
561 tokio::spawn(async move {
562 let result =
563 notify_legacy_push(&peer, &minimum_level, delivery.data)
564 .await
565 .map_err(|error| error.to_string());
566 if let Err(err) = result {
567 tracing::warn!(
568 proto_id = delivery.proto_id,
569 session_id = delivery.session_id,
570 owner_key_id = delivery.owner_key_id.as_deref().unwrap_or("<none>"),
571 error = %err,
572 "mcp push notification send failed"
573 );
574 }
575 });
576 }
577 SubscriberDelivery::ModernResource(resource) => {
578 if let Some(work) = resource.enqueue(delivery.data) {
579 tokio::spawn(async move {
580 resource.send_pending_notification(work).await;
581 });
582 }
583 }
584 }
585 }
586 }
587 });
588
589 let subs_for_purge = Arc::downgrade(&self.push_subscribers);
594 if self
595 .purge_task_spawns
596 .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
597 .is_ok()
598 {
599 tokio::spawn(async move {
600 use std::time::Duration;
601 const PURGE_INTERVAL: Duration = Duration::from_secs(5 * 60);
602 let mut ticker = tokio::time::interval(PURGE_INTERVAL);
603 ticker.tick().await; loop {
605 ticker.tick().await;
606 let Some(subs_for_purge) = subs_for_purge.upgrade() else {
607 break;
608 };
609 let now = std::time::Instant::now();
610 let removed = {
611 let mut subs = subs_for_purge.lock().await;
612 let expired = subs
613 .iter()
614 .filter_map(|(handle, sub)| {
615 now.checked_duration_since(sub.registered_at)
616 .is_some_and(|age| {
617 age >= push_subscribers::PUSH_SUBSCRIBER_MAX_AGE
618 })
619 .then_some(handle.clone())
620 })
621 .collect::<Vec<_>>();
622 expired
623 .into_iter()
624 .filter_map(|handle| subs.remove(&handle))
625 .collect::<Vec<_>>()
626 };
627 let purged = removed.len();
628 for subscriber in removed {
629 subscriber.close_modern_resource();
630 }
631 if purged > 0 {
632 let remaining = subs_for_purge.lock().await.len();
633 tracing::info!(
634 purged,
635 remaining,
636 max_age_secs = push_subscribers::PUSH_SUBSCRIBER_MAX_AGE.as_secs(),
637 "v1.4.39 Phase 5: purged stale push subscribers (> 4h registered)"
638 );
639 }
640 }
641 });
642 }
643
644 drop(_connect_gate);
648 if generation > 1 {
649 self.restore_push_registration(&arc).await;
650 }
651
652 Ok(arc)
653 }
654
655 #[cfg(test)]
657 pub fn push_registration_dirty(&self) -> bool {
658 self.push_registration_dirty
659 .load(std::sync::atomic::Ordering::Acquire)
660 }
661
662 #[cfg(test)]
665 pub fn push_reregister_failures(&self) -> u64 {
666 self.push_reregister_failures
667 .load(std::sync::atomic::Ordering::Relaxed)
668 }
669
670 async fn restore_push_registration(&self, client: &Arc<FutuClient>) {
676 let acc_ids = {
677 let subs = self.push_subscribers.lock().await;
678 if subs.is_empty() {
679 return;
680 }
681 let mut acc_ids = subs
682 .values()
683 .flat_map(|sub| sub.acc_ids.iter().copied())
684 .collect::<Vec<u64>>();
685 acc_ids.sort_unstable();
686 acc_ids.dedup();
687 acc_ids
688 };
689 match crate::handlers::trade::sub_acc_push(client, &acc_ids).await {
690 Ok(_) => {
691 self.push_registration_dirty
692 .store(false, std::sync::atomic::Ordering::Release);
693 tracing::info!(
694 accounts = ?RedactedAccountIdSet::new(&acc_ids),
695 "mcp push subscription re-registered"
696 );
697 }
698 Err(err) => {
699 self.push_registration_dirty
700 .store(true, std::sync::atomic::Ordering::Release);
701 self.push_reregister_failures
702 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
703 tracing::warn!(
704 error = ?RedactedMcpError::new(&err),
705 accounts = ?RedactedAccountIdSet::new(&acc_ids),
706 "mcp push subscription re-register failed after gateway reconnect; \
707 callers must re-run futu_sub_acc_push"
708 );
709 }
710 }
711 }
712
713 pub async fn verification_client(&self) -> Result<Arc<FutuClient>> {
717 let gateway = self.gateway().await;
718 let config = ClientConfig {
719 addr: gateway.clone(),
720 client_ver: env!("CARGO_PKG_VERSION").to_string(),
721 client_id: futu_core::INTERNAL_UI_CLIENT_ID.to_string(),
722 recv_notify: false,
723 rsa_key: None,
724 };
725 let policy =
726 ReconnectPolicy::new(MCP_CONNECT_RETRY_DELAY, MCP_CONNECT_RETRY_DELAY, Some(1));
727 let mut reconnector = ReconnectingClient::new(config).with_policy(policy);
728 let connect_result =
729 tokio::time::timeout(MCP_CONNECT_TOTAL_TIMEOUT, reconnector.connect()).await;
730 let (client, _push_rx, _info) = match connect_result {
731 Ok(result) => result.with_context(|| {
732 format!("connect Verification transport to futu gateway at {gateway}")
733 })?,
734 Err(_) => {
735 return Err(anyhow!(
736 "connect Verification transport to futu gateway at {gateway} timed out after {}s",
737 MCP_CONNECT_TOTAL_TIMEOUT.as_secs()
738 ));
739 }
740 };
741 Ok(Arc::new(client))
742 }
743}
744
745pub fn parse_symbol(s: &str) -> Result<Security> {
748 let parsed = qot_symbol::parse_qot_symbol_parts(s).map_err(|err| anyhow!("{err}"))?;
749 Ok(qot_sdk_adapter::security_from_parsed_symbol(parsed))
750}
751
752pub fn format_symbol(sec: &Security) -> String {
754 qot_symbol::format_qot_symbol(sec.market as i32, &sec.code)
755}
756
757pub mod audit_fmt {
773 #[inline]
775 pub fn opt_f64(v: Option<f64>) -> f64 {
776 v.unwrap_or(f64::NAN)
777 }
778
779 #[inline]
782 pub fn opt_i32(v: Option<i32>) -> f64 {
783 v.map(f64::from).unwrap_or(f64::NAN)
784 }
785
786 #[inline]
789 pub fn opt_str(v: Option<&str>) -> &str {
790 v.unwrap_or("")
791 }
792}