Skip to main content

futu_mcp/
state.rs

1//! 共享状态:网关连接 + 订阅状态 + 授权
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::sync::atomic::Ordering;
6
7use anyhow::{Context, Result, anyhow};
8use futu_auth::{KeyRecord, KeyStore, RuntimeCounters};
9use futu_core::qot_symbol;
10use futu_net::client::{ClientConfig, FutuClient, ReconnectingClient};
11use futu_net::reconnect::ReconnectPolicy;
12use futu_qot::types::Security;
13use rmcp::{RoleServer, service::Peer};
14use tokio::sync::{Mutex, Semaphore};
15
16mod push_filter;
17mod push_subscribers;
18pub(crate) use push_subscribers::{LegacyPushServiceLease, PushDeliveryTarget};
19#[cfg(test)]
20pub(crate) use push_subscribers::{MAX_MODERN_PUSH_HANDLES, MODERN_PUSH_QUEUE_CAPACITY};
21pub(crate) use push_subscribers::{parse_push_resource_uri, push_resource_uri};
22#[cfg(test)]
23mod tests;
24
25use push_filter::{TradePushDecode, classify_trade_push, trd_market_int_to_str};
26#[cfg(test)]
27use push_filter::{
28    extract_acc_id_and_market_from_push, is_trade_push_proto_id, subscriber_should_receive,
29    subscriber_should_receive_with_market, subscriber_visible_to_caller,
30};
31use push_subscribers::{PushSubscriber, SubscriberDelivery};
32
33use crate::qot_sdk_adapter;
34
35const MCP_CONNECT_TOTAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
36const MCP_CONNECT_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(200);
37pub(crate) const LEGACY_PUSH_INFO_LEVEL_RANK: u8 = 1;
38
39/// v1.4.38 Phase 5 helper: bytes → base64 (用于 push body 安全包进 JSON)
40fn base64_encode_bytes(bytes: &[u8]) -> String {
41    use base64::Engine as _;
42    base64::engine::general_purpose::STANDARD.encode(bytes)
43}
44
45struct PendingPushDelivery {
46    target: SubscriberDelivery,
47    data: serde_json::Value,
48    session_id: String,
49    owner_key_id: Option<String>,
50    proto_id: u32,
51}
52
53/// Single authorization admission shared by legacy logging and modern resource
54/// delivery. A caller must pass this function before either target is created,
55/// so decode-failed restricted trade bodies and current scope revocations can
56/// never enter a modern queue.
57fn push_delivery_is_authorized(
58    subscriber: &PushSubscriber,
59    key_store: &KeyStore,
60    filter_registry: &futu_auth_pipeline::FilterRegistry,
61    decode_result: &TradePushDecode,
62    event_type: &'static str,
63    push_acc_id: Option<u64>,
64    push_trd_market_str: Option<&'static str>,
65    proto_id: u32,
66) -> bool {
67    let (allowed_acc_ids, allowed_markets) = match &subscriber.delivery {
68        SubscriberDelivery::LegacyPeer(_, _) => (
69            subscriber.allowed_acc_ids_snapshot.clone(),
70            subscriber.allowed_markets_snapshot.clone(),
71        ),
72        SubscriberDelivery::ModernResource(_) => {
73            let Some(owner) = subscriber.owner_key_id.as_deref() else {
74                return false;
75            };
76            let Some(current) = key_store
77                .get_by_id_for_current_machine(owner)
78                .filter(|record| {
79                    !record.is_expired(chrono::Utc::now())
80                        && record.scopes.contains(&futu_auth::Scope::AccRead)
81                })
82            else {
83                return false;
84            };
85            (
86                current.allowed_acc_ids.clone(),
87                current.allowed_markets.clone(),
88            )
89        }
90    };
91    if matches!(decode_result, TradePushDecode::DecodeFailed) {
92        let restricted = allowed_acc_ids
93            .as_ref()
94            .is_some_and(|allowed| !allowed.is_empty());
95        if restricted {
96            let key_id = subscriber.owner_key_id.as_deref().unwrap_or("<none>");
97            futu_auth::metrics::bump_ws_filtered("trade_decode_failed", key_id);
98            tracing::warn!(
99                proto_id,
100                key_id,
101                "MCP trade push body decode failed; dropped before legacy/modern delivery for restricted key"
102            );
103            return false;
104        }
105    }
106    let sub_state = (!subscriber.acc_ids.is_empty()).then_some(&subscriber.acc_ids);
107    let ctx = futu_auth_pipeline::PushEventCtx {
108        event_type,
109        event_acc: push_acc_id,
110        allowed_acc_ids: allowed_acc_ids.as_ref(),
111        sub_state,
112        event_trd_market: push_trd_market_str,
113        allowed_markets: allowed_markets.as_ref(),
114    };
115    if filter_registry.should_drop_event(&ctx) {
116        let key_id = subscriber.owner_key_id.as_deref().unwrap_or("<none>");
117        futu_auth::metrics::bump_ws_filtered("trade_market", key_id);
118        return false;
119    }
120    true
121}
122
123#[allow(deprecated)]
124async fn notify_legacy_push(
125    peer: &Peer<RoleServer>,
126    minimum_level: &std::sync::atomic::AtomicU8,
127    data: serde_json::Value,
128) -> Result<(), rmcp::service::ServiceError> {
129    if minimum_level.load(Ordering::Acquire) > LEGACY_PUSH_INFO_LEVEL_RANK {
130        return Ok(());
131    }
132    let params =
133        rmcp::model::LoggingMessageNotificationParam::new(rmcp::model::LoggingLevel::Info, data)
134            .with_logger("futu_push");
135    peer.notify_logging_message(params).await
136}
137
138/// MCP server 运行时状态
139#[derive(Clone)]
140pub struct ServerState {
141    /// [`Inner`] 共享可变状态(gateway 地址 + 懒加载的 [`FutuClient`])
142    inner: Arc<Mutex<Inner>>,
143    /// 是否启用交易写工具(place/modify/cancel)。默认 false。旧开关,仅当
144    /// `key_store.is_configured() == false` 时生效。
145    enable_trading: bool,
146    /// 是否允许对 real 环境下单。默认 false。旧开关,同上。
147    allow_real_trading: bool,
148    /// keys.json 加载的 KeyStore。`is_configured()` 为 true 时走 scope 授权模式。
149    key_store: Arc<KeyStore>,
150    /// 调用方传入的 API Key 对应的记录;None 表示未提供 key。
151    authed_key: Option<Arc<KeyRecord>>,
152    /// 交易密码所属登录账号。用于 `futu_unlock_trade` 从账号级 keychain
153    /// `trade-password.<login-account>` 读取密码;None 时走 legacy/global/env 兼容路径。
154    trade_pwd_account: Option<String>,
155    /// 限额运行时(日累计计数器)
156    counters: Arc<RuntimeCounters>,
157    /// v1.4.38 Phase 5: MCP push 订阅者注册表(session_uuid → subscriber)。
158    /// `futu_sub_acc_push` 工具在 HTTP 模式下调用时注册当前 session,daemon
159    /// push 到 MCP 后按 acc_id filter 向注册的 peer 发
160    /// `notify_logging_message`(server-initiated notification)。
161    push_subscribers: Arc<Mutex<HashMap<String, PushSubscriber>>>,
162    /// Modern resources retain bounded queues for up to 4h. A semaphore makes
163    /// handle cardinality admission atomic and lets the tool fail before any
164    /// daemon side effect. The fixed local limit is a memory-safety boundary,
165    /// not backend/config data; raise it only after measuring worst-case push
166    /// payload memory and concurrent client demand.
167    modern_push_slots: Arc<Semaphore>,
168}
169
170/// ServerState 内部可变部分,加锁存放 gateway 地址 + 懒加载的 [`FutuClient`]。
171struct Inner {
172    /// 网关 TCP 地址(如 `127.0.0.1:11111`)
173    gateway: String,
174    /// 懒加载的底层连接;首次调用 [`ServerState::client`] 时建立,后续复用
175    client: Option<Arc<FutuClient>>,
176}
177
178impl ServerState {
179    /// 创建默认 state:`enable_trading=false` / `allow_real_trading=false` /
180    /// 空 [`KeyStore`] / 无 authed_key。使用 `with_*` 链式方法注入额外能力。
181    pub fn new(gateway: String) -> Self {
182        Self {
183            inner: Arc::new(Mutex::new(Inner {
184                gateway,
185                client: None,
186            })),
187            enable_trading: false,
188            allow_real_trading: false,
189            key_store: Arc::new(KeyStore::empty()),
190            authed_key: None,
191            trade_pwd_account: None,
192            counters: Arc::new(RuntimeCounters::new()),
193            push_subscribers: Arc::new(Mutex::new(HashMap::new())),
194            modern_push_slots: Arc::new(Semaphore::new(push_subscribers::MAX_MODERN_PUSH_HANDLES)),
195        }
196    }
197
198    /// 启用交易写工具(构造器式链式设置)
199    pub fn with_trading(mut self, enable_trading: bool, allow_real_trading: bool) -> Self {
200        self.enable_trading = enable_trading;
201        self.allow_real_trading = allow_real_trading;
202        self
203    }
204
205    /// 设置 KeyStore(新授权模式)
206    pub fn with_key_store(mut self, store: Arc<KeyStore>) -> Self {
207        self.key_store = store;
208        self
209    }
210
211    /// 设置已通过验证的 API Key 记录
212    pub fn with_authed_key(mut self, key: Option<Arc<KeyRecord>>) -> Self {
213        self.authed_key = key;
214        self
215    }
216
217    /// 设置交易密码所属登录账号(MCP 只连 gateway,本身无法可靠推断 daemon
218    /// 的 login account;由 CLI/env/config 显式注入)。
219    pub fn with_trade_pwd_account(mut self, account: Option<String>) -> Self {
220        self.trade_pwd_account = account;
221        self
222    }
223
224    /// 是否启用了 scope 授权模式
225    pub fn is_scope_mode(&self) -> bool {
226        self.key_store.is_configured()
227    }
228
229    /// 交易写工具开关(legacy mode)。
230    pub fn enable_trading(&self) -> bool {
231        self.enable_trading
232    }
233
234    /// real 环境交易写工具开关(legacy mode)。
235    pub fn allow_real_trading(&self) -> bool {
236        self.allow_real_trading
237    }
238
239    /// 当前 MCP API key store。返回共享引用,避免调用方替换 runtime storage。
240    pub fn key_store(&self) -> &Arc<KeyStore> {
241        &self.key_store
242    }
243
244    /// startup 阶段验证过的 key 快照;调用方需要 fresh record 时仍应按 id 回查 key store。
245    pub fn authed_key(&self) -> Option<Arc<KeyRecord>> {
246        self.authed_key.clone()
247    }
248
249    /// 交易密码所属登录账号。
250    pub fn trade_pwd_account(&self) -> Option<&str> {
251        self.trade_pwd_account.as_deref()
252    }
253
254    /// 限额运行时计数器。返回共享引用,避免调用方替换 runtime storage。
255    pub fn counters(&self) -> &Arc<RuntimeCounters> {
256        &self.counters
257    }
258
259    /// 当前配置的 gateway 地址。
260    pub async fn gateway(&self) -> String {
261        self.inner.lock().await.gateway.clone()
262    }
263
264    /// 获取(或懒加载)网关客户端
265    pub async fn client(&self) -> Result<Arc<FutuClient>> {
266        let gateway = {
267            let guard = self.inner.lock().await;
268            if let Some(c) = &guard.client {
269                return Ok(c.clone());
270            }
271            guard.gateway.clone()
272        };
273
274        let config = ClientConfig {
275            addr: gateway.clone(),
276            client_ver: env!("CARGO_PKG_VERSION").to_string(),
277            client_id: "futu-mcp".to_string(),
278            recv_notify: false,
279            rsa_key: None,
280        };
281        let policy =
282            ReconnectPolicy::new(MCP_CONNECT_RETRY_DELAY, MCP_CONNECT_RETRY_DELAY, Some(1));
283        let mut reconnector = ReconnectingClient::new(config).with_policy(policy);
284        let connect_result =
285            tokio::time::timeout(MCP_CONNECT_TOTAL_TIMEOUT, reconnector.connect()).await;
286        let (client, mut push_rx, _info) = match connect_result {
287            Ok(result) => {
288                result.with_context(|| format!("connect to futu gateway at {gateway}"))?
289            }
290            Err(_) => {
291                return Err(anyhow!(
292                    "connect to futu gateway at {gateway} timed out after {}s",
293                    MCP_CONNECT_TOTAL_TIMEOUT.as_secs()
294                ));
295            }
296        };
297
298        let arc = Arc::new(client);
299        {
300            let mut guard = self.inner.lock().await;
301            if let Some(c) = &guard.client {
302                return Ok(c.clone());
303            }
304            guard.client = Some(arc.clone());
305        }
306
307        // v1.4.38 Phase 5 (100%): 按 acc_ids 过滤的 push broadcast
308        //
309        // 流程:
310        // 1. push_rx 收 daemon 转发的 push
311        // 2. 对 TRD_UPDATE_ORDER (2208) / TRD_UPDATE_ORDER_FILL (2218) 解包
312        //    提取 acc_id
313        // 3. 遍历订阅者,**只推给 acc_ids 匹配的**(或订阅者 acc_ids 空 = 不
314        //    过滤,所有 acc 都收)
315        // 4. 行情 push(QOT_UPDATE_*)无 acc_id 语义,广播给所有订阅者
316        //
317        // Per-session 独立 spawn notify,避免一个慢 session 阻塞其他
318        let subs_for_push = Arc::downgrade(&self.push_subscribers);
319        let key_store_for_push = Arc::downgrade(&self.key_store);
320        // v1.4.105 F5 fix (codex review C4 USER_ACK B): MCP push filter 改用
321        // FilterRegistry::should_drop_event 单一注册中心 (跟 4 surface 一致),
322        // 替代之前 inline subscriber_should_receive_with_market. 防 sibling-route
323        // bypass — 加新 push event filter 维度只在 install_defaults 注册一次,
324        // MCP 自动覆盖.
325        let filter_registry =
326            std::sync::Arc::new(futu_auth_pipeline::FilterRegistry::with_defaults());
327        tokio::spawn(async move {
328            while let Some(push) = push_rx.recv().await {
329                let Some(subs_for_push) = subs_for_push.upgrade() else {
330                    break;
331                };
332                let Some(key_store_for_push) = key_store_for_push.upgrade() else {
333                    break;
334                };
335                let subscribers = {
336                    let subs = subs_for_push.lock().await;
337                    if subs.is_empty() {
338                        Vec::new()
339                    } else {
340                        subs.iter()
341                            .map(|(session_id, sub)| (session_id.clone(), sub.clone()))
342                            .collect::<Vec<_>>()
343                    }
344                };
345                if subscribers.is_empty() {
346                    continue; // fast path: no listeners, drop
347                }
348                // v1.4.105 T-C2 + v1.4.106 codex 0932 F6/F7: classify push by proto_id
349                // (set membership), 不再靠 body decode 成功推断. trade body decode
350                // 失败现在归 TradePushDecode::DecodeFailed (event_type="trade",
351                // 无 acc/market gate 信息) — restricted key 应 drop, unrestricted
352                // 透传带 decode_status="failed".
353                let decode_result = classify_trade_push(push.proto_id, &push.body);
354                let (push_acc_id, push_trd_market, decode_status, event_type) = match &decode_result
355                {
356                    TradePushDecode::NotTrade => (None, None, "ok", "quote"),
357                    TradePushDecode::Decoded { acc_id, trd_market } => {
358                        (Some(*acc_id), Some(*trd_market), "ok", "trade")
359                    }
360                    TradePushDecode::DecodeFailed => (None, None, "failed", "trade"),
361                };
362                let push_trd_market_str = push_trd_market.map(trd_market_int_to_str);
363                // v1.4.106 codex 0932 F7 [P3]: payload 加 event_type / trd_market /
364                // decode_status — 让客户端不需要按 proto_id 自己 derive (4 surface 一致).
365                // body_base64 后向兼容保留.
366                let payload = serde_json::json!({
367                    "kind": "futu_push",
368                    "proto_id": push.proto_id,
369                    "acc_id": push_acc_id,
370                    "event_type": event_type,
371                    "trd_market": push_trd_market_str,
372                    "decode_status": decode_status,
373                    "body_base64": base64_encode_bytes(&push.body),
374                });
375                let deliveries = {
376                    let mut deliveries = Vec::with_capacity(subscribers.len());
377                    for (session_id, sub) in subscribers.iter() {
378                        if !push_delivery_is_authorized(
379                            sub,
380                            &key_store_for_push,
381                            &filter_registry,
382                            &decode_result,
383                            event_type,
384                            push_acc_id,
385                            push_trd_market_str,
386                            push.proto_id,
387                        ) {
388                            continue;
389                        }
390                        deliveries.push(PendingPushDelivery {
391                            target: sub.delivery.clone(),
392                            data: payload.clone(),
393                            session_id: session_id.clone(),
394                            owner_key_id: sub.owner_key_id.clone(),
395                            proto_id: push.proto_id,
396                        });
397                    }
398                    deliveries
399                };
400                for delivery in deliveries {
401                    match delivery.target {
402                        SubscriberDelivery::LegacyPeer(peer, minimum_level) => {
403                            tokio::spawn(async move {
404                                let result =
405                                    notify_legacy_push(&peer, &minimum_level, delivery.data)
406                                        .await
407                                        .map_err(|error| error.to_string());
408                                if let Err(err) = result {
409                                    tracing::warn!(
410                                        proto_id = delivery.proto_id,
411                                        session_id = delivery.session_id,
412                                        owner_key_id = delivery.owner_key_id.as_deref().unwrap_or("<none>"),
413                                        error = %err,
414                                        "mcp push notification send failed"
415                                    );
416                                }
417                            });
418                        }
419                        SubscriberDelivery::ModernResource(resource) => {
420                            if let Some(work) = resource.enqueue(delivery.data) {
421                                tokio::spawn(async move {
422                                    resource.send_pending_notification(work).await;
423                                });
424                            }
425                        }
426                    }
427                }
428            }
429        });
430
431        // v1.4.39 Phase 5 stale cleanup: 5 分钟跑一次,移除 registered_at > 4h
432        // 的订阅者。避免长跑 daemon 累积陈旧 subscriber(客户端断开 /  rmcp
433        // session gone 但没显式 unregister 的情况)。
434        let subs_for_purge = Arc::downgrade(&self.push_subscribers);
435        tokio::spawn(async move {
436            use std::time::Duration;
437            const PURGE_INTERVAL: Duration = Duration::from_secs(5 * 60);
438            let mut ticker = tokio::time::interval(PURGE_INTERVAL);
439            ticker.tick().await; // skip the immediate first tick
440            loop {
441                ticker.tick().await;
442                let Some(subs_for_purge) = subs_for_purge.upgrade() else {
443                    break;
444                };
445                let now = std::time::Instant::now();
446                let removed = {
447                    let mut subs = subs_for_purge.lock().await;
448                    let expired = subs
449                        .iter()
450                        .filter_map(|(handle, sub)| {
451                            now.checked_duration_since(sub.registered_at)
452                                .is_some_and(|age| age >= push_subscribers::PUSH_SUBSCRIBER_MAX_AGE)
453                                .then_some(handle.clone())
454                        })
455                        .collect::<Vec<_>>();
456                    expired
457                        .into_iter()
458                        .filter_map(|handle| subs.remove(&handle))
459                        .collect::<Vec<_>>()
460                };
461                let purged = removed.len();
462                for subscriber in removed {
463                    subscriber.close_modern_resource();
464                }
465                if purged > 0 {
466                    let remaining = subs_for_purge.lock().await.len();
467                    tracing::info!(
468                        purged,
469                        remaining,
470                        max_age_secs = push_subscribers::PUSH_SUBSCRIBER_MAX_AGE.as_secs(),
471                        "v1.4.39 Phase 5: purged stale push subscribers (> 4h registered)"
472                    );
473                }
474            }
475        });
476
477        Ok(arc)
478    }
479
480    /// Open a transient C++-compatible internal-UI transport for the pre-login
481    /// Verification tool. The ordinary cached MCP client must keep normal
482    /// InitConnect semantics and wait for a real login identity.
483    pub async fn verification_client(&self) -> Result<Arc<FutuClient>> {
484        let gateway = self.gateway().await;
485        let config = ClientConfig {
486            addr: gateway.clone(),
487            client_ver: env!("CARGO_PKG_VERSION").to_string(),
488            client_id: futu_core::INTERNAL_UI_CLIENT_ID.to_string(),
489            recv_notify: false,
490            rsa_key: None,
491        };
492        let policy =
493            ReconnectPolicy::new(MCP_CONNECT_RETRY_DELAY, MCP_CONNECT_RETRY_DELAY, Some(1));
494        let mut reconnector = ReconnectingClient::new(config).with_policy(policy);
495        let connect_result =
496            tokio::time::timeout(MCP_CONNECT_TOTAL_TIMEOUT, reconnector.connect()).await;
497        let (client, _push_rx, _info) = match connect_result {
498            Ok(result) => result.with_context(|| {
499                format!("connect Verification transport to futu gateway at {gateway}")
500            })?,
501            Err(_) => {
502                return Err(anyhow!(
503                    "connect Verification transport to futu gateway at {gateway} timed out after {}s",
504                    MCP_CONNECT_TOTAL_TIMEOUT.as_secs()
505                ));
506            }
507        };
508        Ok(Arc::new(client))
509    }
510}
511
512// ========== symbol 解析 ==========
513
514pub fn parse_symbol(s: &str) -> Result<Security> {
515    let parsed = qot_symbol::parse_qot_symbol_parts(s).map_err(|err| anyhow!("{err}"))?;
516    Ok(qot_sdk_adapter::security_from_parsed_symbol(parsed))
517}
518
519/// 格式化 Security 为 "MARKET.CODE"
520pub fn format_symbol(sec: &Security) -> String {
521    qot_symbol::format_qot_symbol(sec.market as i32, &sec.code)
522}
523
524/// v1.4.90 P2-C: audit log Option<T> 序列化助手。
525///
526/// **背景**:之前 audit log 把 `Option<f64>` 用 `?req.price`(tracing 的 Debug
527/// shorthand)记录,渲染成 JSON 字符串 `"Some(400.0)"` / `"None"`,下游 jq /
528/// DuckDB 数值聚合炸(aggregator 期望 `400.0` number 或 `null`)。
529///
530/// **修法**:用 NaN sentinel 把 `Option<f64>` flatten 成 `f64`,tracing-subscriber
531/// 的 JSON formatter 内部走 `serde_json::Value::from(f64::NAN)` →
532/// `Number::from_f64(NaN) = None` → `Value::Null`。
533/// 整数 / 字符串同理(i32 → f64 NaN sentinel;&str → "" 哨兵)。
534///
535/// 验证依据:
536/// - `tracing_subscriber::fmt::format::json` line 501 `record_f64` 直接调
537///   `serde_json::Value::from(value)`
538/// - `serde_json::Value::from(f64)` impl: `Number::from_f64(f).map_or(Value::Null, Value::Number)`
539pub mod audit_fmt {
540    /// `Option<f64>` → `f64`(None → NaN)。tracing JSON 渲染 NaN 为 `null`。
541    #[inline]
542    pub fn opt_f64(v: Option<f64>) -> f64 {
543        v.unwrap_or(f64::NAN)
544    }
545
546    /// `Option<i32>` → `f64`(None → NaN,Some(n) → n as f64)。
547    /// i32 ≤ 2^31 < 2^52 mantissa,无精度损失。
548    #[inline]
549    pub fn opt_i32(v: Option<i32>) -> f64 {
550        v.map(f64::from).unwrap_or(f64::NAN)
551    }
552
553    /// `Option<&str>` → `&str`(None → "")。"" 哨兵在 audit 上下文里足以区分
554    /// 不传 vs 传空(因为 Symbol / owner 等业务字段不会是空字符串)。
555    #[inline]
556    pub fn opt_str(v: Option<&str>) -> &str {
557        v.unwrap_or("")
558    }
559}