Skip to main content

futu_mcp/
state.rs

1//! 共享状态:网关连接 + 订阅状态 + 授权
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use anyhow::{Context, Result, anyhow};
7use futu_auth::{KeyRecord, KeyStore, RuntimeCounters};
8use futu_net::client::{ClientConfig, FutuClient, ReconnectingClient};
9use futu_net::reconnect::ReconnectPolicy;
10use futu_qot::types::Security;
11use rmcp::{RoleServer, service::Peer};
12use tokio::sync::Mutex;
13
14mod push_filter;
15mod push_subscribers;
16#[cfg(test)]
17mod tests;
18
19use push_filter::{TradePushDecode, classify_trade_push, trd_market_int_to_str};
20#[cfg(test)]
21use push_filter::{
22    extract_acc_id_and_market_from_push, is_trade_push_proto_id, subscriber_should_receive,
23    subscriber_should_receive_with_market, subscriber_visible_to_caller,
24};
25use push_subscribers::PushSubscriber;
26
27const MCP_CONNECT_TOTAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
28const MCP_CONNECT_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(200);
29
30/// v1.4.38 Phase 5 helper: bytes → base64 (用于 push body 安全包进 JSON)
31fn base64_encode_bytes(bytes: &[u8]) -> String {
32    use base64::Engine as _;
33    base64::engine::general_purpose::STANDARD.encode(bytes)
34}
35
36struct PushDelivery {
37    peer: Peer<RoleServer>,
38    data: serde_json::Value,
39    session_id: String,
40    owner_key_id: Option<String>,
41    proto_id: u32,
42}
43
44/// MCP server 运行时状态
45#[derive(Clone)]
46pub struct ServerState {
47    /// [`Inner`] 共享可变状态(gateway 地址 + 懒加载的 [`FutuClient`])
48    inner: Arc<Mutex<Inner>>,
49    /// 是否启用交易写工具(place/modify/cancel)。默认 false。旧开关,仅当
50    /// `key_store.is_configured() == false` 时生效。
51    enable_trading: bool,
52    /// 是否允许对 real 环境下单。默认 false。旧开关,同上。
53    allow_real_trading: bool,
54    /// keys.json 加载的 KeyStore。`is_configured()` 为 true 时走 scope 授权模式。
55    key_store: Arc<KeyStore>,
56    /// 调用方传入的 API Key 对应的记录;None 表示未提供 key。
57    authed_key: Option<Arc<KeyRecord>>,
58    /// 交易密码所属登录账号。用于 `futu_unlock_trade` 从账号级 keychain
59    /// `trade-password.<login-account>` 读取密码;None 时走 legacy/global/env 兼容路径。
60    trade_pwd_account: Option<String>,
61    /// 限额运行时(日累计计数器)
62    counters: Arc<RuntimeCounters>,
63    /// v1.4.38 Phase 5: MCP push 订阅者注册表(session_uuid → subscriber)。
64    /// `futu_sub_acc_push` 工具在 HTTP 模式下调用时注册当前 session,daemon
65    /// push 到 MCP 后按 acc_id filter 向注册的 peer 发
66    /// `notify_logging_message`(server-initiated notification)。
67    push_subscribers: Arc<Mutex<HashMap<String, PushSubscriber>>>,
68}
69
70/// ServerState 内部可变部分,加锁存放 gateway 地址 + 懒加载的 [`FutuClient`]。
71struct Inner {
72    /// 网关 TCP 地址(如 `127.0.0.1:11111`)
73    gateway: String,
74    /// 懒加载的底层连接;首次调用 [`ServerState::client`] 时建立,后续复用
75    client: Option<Arc<FutuClient>>,
76}
77
78impl ServerState {
79    /// 创建默认 state:`enable_trading=false` / `allow_real_trading=false` /
80    /// 空 [`KeyStore`] / 无 authed_key。使用 `with_*` 链式方法注入额外能力。
81    pub fn new(gateway: String) -> Self {
82        Self {
83            inner: Arc::new(Mutex::new(Inner {
84                gateway,
85                client: None,
86            })),
87            enable_trading: false,
88            allow_real_trading: false,
89            key_store: Arc::new(KeyStore::empty()),
90            authed_key: None,
91            trade_pwd_account: None,
92            counters: Arc::new(RuntimeCounters::new()),
93            push_subscribers: Arc::new(Mutex::new(HashMap::new())),
94        }
95    }
96
97    /// 启用交易写工具(构造器式链式设置)
98    pub fn with_trading(mut self, enable_trading: bool, allow_real_trading: bool) -> Self {
99        self.enable_trading = enable_trading;
100        self.allow_real_trading = allow_real_trading;
101        self
102    }
103
104    /// 设置 KeyStore(新授权模式)
105    pub fn with_key_store(mut self, store: Arc<KeyStore>) -> Self {
106        self.key_store = store;
107        self
108    }
109
110    /// 设置已通过验证的 API Key 记录
111    pub fn with_authed_key(mut self, key: Option<Arc<KeyRecord>>) -> Self {
112        self.authed_key = key;
113        self
114    }
115
116    /// 设置交易密码所属登录账号(MCP 只连 gateway,本身无法可靠推断 daemon
117    /// 的 login account;由 CLI/env/config 显式注入)。
118    pub fn with_trade_pwd_account(mut self, account: Option<String>) -> Self {
119        self.trade_pwd_account = account;
120        self
121    }
122
123    /// 是否启用了 scope 授权模式
124    pub fn is_scope_mode(&self) -> bool {
125        self.key_store.is_configured()
126    }
127
128    /// 交易写工具开关(legacy mode)。
129    pub fn enable_trading(&self) -> bool {
130        self.enable_trading
131    }
132
133    /// real 环境交易写工具开关(legacy mode)。
134    pub fn allow_real_trading(&self) -> bool {
135        self.allow_real_trading
136    }
137
138    /// 当前 MCP API key store。返回共享引用,避免调用方替换 runtime storage。
139    pub fn key_store(&self) -> &Arc<KeyStore> {
140        &self.key_store
141    }
142
143    /// startup 阶段验证过的 key 快照;调用方需要 fresh record 时仍应按 id 回查 key store。
144    pub fn authed_key(&self) -> Option<Arc<KeyRecord>> {
145        self.authed_key.clone()
146    }
147
148    /// 交易密码所属登录账号。
149    pub fn trade_pwd_account(&self) -> Option<&str> {
150        self.trade_pwd_account.as_deref()
151    }
152
153    /// 限额运行时计数器。返回共享引用,避免调用方替换 runtime storage。
154    pub fn counters(&self) -> &Arc<RuntimeCounters> {
155        &self.counters
156    }
157
158    /// 当前配置的 gateway 地址。
159    pub async fn gateway(&self) -> String {
160        self.inner.lock().await.gateway.clone()
161    }
162
163    /// 获取(或懒加载)网关客户端
164    pub async fn client(&self) -> Result<Arc<FutuClient>> {
165        let gateway = {
166            let guard = self.inner.lock().await;
167            if let Some(c) = &guard.client {
168                return Ok(c.clone());
169            }
170            guard.gateway.clone()
171        };
172
173        let config = ClientConfig {
174            addr: gateway.clone(),
175            client_ver: env!("CARGO_PKG_VERSION").to_string(),
176            client_id: "futu-mcp".to_string(),
177            recv_notify: false,
178            rsa_key: None,
179        };
180        let policy =
181            ReconnectPolicy::new(MCP_CONNECT_RETRY_DELAY, MCP_CONNECT_RETRY_DELAY, Some(1));
182        let mut reconnector = ReconnectingClient::new(config).with_policy(policy);
183        let connect_result =
184            tokio::time::timeout(MCP_CONNECT_TOTAL_TIMEOUT, reconnector.connect()).await;
185        let (client, mut push_rx, _info) = match connect_result {
186            Ok(result) => {
187                result.with_context(|| format!("connect to futu gateway at {gateway}"))?
188            }
189            Err(_) => {
190                return Err(anyhow!(
191                    "connect to futu gateway at {gateway} timed out after {}s",
192                    MCP_CONNECT_TOTAL_TIMEOUT.as_secs()
193                ));
194            }
195        };
196
197        let arc = Arc::new(client);
198        {
199            let mut guard = self.inner.lock().await;
200            if let Some(c) = &guard.client {
201                return Ok(c.clone());
202            }
203            guard.client = Some(arc.clone());
204        }
205
206        // v1.4.38 Phase 5 (100%): 按 acc_ids 过滤的 push broadcast
207        //
208        // 流程:
209        // 1. push_rx 收 daemon 转发的 push
210        // 2. 对 TRD_UPDATE_ORDER (2208) / TRD_UPDATE_ORDER_FILL (2218) 解包
211        //    提取 acc_id
212        // 3. 遍历订阅者,**只推给 acc_ids 匹配的**(或订阅者 acc_ids 空 = 不
213        //    过滤,所有 acc 都收)
214        // 4. 行情 push(QOT_UPDATE_*)无 acc_id 语义,广播给所有订阅者
215        //
216        // Per-session 独立 spawn notify,避免一个慢 session 阻塞其他
217        let subs_for_push = Arc::downgrade(&self.push_subscribers);
218        // v1.4.105 F5 fix (codex review C4 USER_ACK B): MCP push filter 改用
219        // FilterRegistry::should_drop_event 单一注册中心 (跟 4 surface 一致),
220        // 替代之前 inline subscriber_should_receive_with_market. 防 sibling-route
221        // bypass — 加新 push event filter 维度只在 install_defaults 注册一次,
222        // MCP 自动覆盖.
223        let filter_registry =
224            std::sync::Arc::new(futu_auth_pipeline::FilterRegistry::with_defaults());
225        tokio::spawn(async move {
226            while let Some(push) = push_rx.recv().await {
227                let Some(subs_for_push) = subs_for_push.upgrade() else {
228                    break;
229                };
230                let subscribers = {
231                    let subs = subs_for_push.lock().await;
232                    if subs.is_empty() {
233                        Vec::new()
234                    } else {
235                        subs.iter()
236                            .map(|(session_id, sub)| (session_id.clone(), sub.clone()))
237                            .collect::<Vec<_>>()
238                    }
239                };
240                if subscribers.is_empty() {
241                    continue; // fast path: no listeners, drop
242                }
243                // v1.4.105 T-C2 + v1.4.106 codex 0932 F6/F7: classify push by proto_id
244                // (set membership), 不再靠 body decode 成功推断. trade body decode
245                // 失败现在归 TradePushDecode::DecodeFailed (event_type="trade",
246                // 无 acc/market gate 信息) — restricted key 应 drop, unrestricted
247                // 透传带 decode_status="failed".
248                let decode_result = classify_trade_push(push.proto_id, &push.body);
249                let (push_acc_id, push_trd_market, decode_status, event_type) = match &decode_result
250                {
251                    TradePushDecode::NotTrade => (None, None, "ok", "quote"),
252                    TradePushDecode::Decoded { acc_id, trd_market } => {
253                        (Some(*acc_id), Some(*trd_market), "ok", "trade")
254                    }
255                    TradePushDecode::DecodeFailed => (None, None, "failed", "trade"),
256                };
257                let push_trd_market_str = push_trd_market.map(trd_market_int_to_str);
258                // v1.4.106 codex 0932 F7 [P3]: payload 加 event_type / trd_market /
259                // decode_status — 让客户端不需要按 proto_id 自己 derive (4 surface 一致).
260                // body_base64 后向兼容保留.
261                let payload = serde_json::json!({
262                    "kind": "futu_push",
263                    "proto_id": push.proto_id,
264                    "acc_id": push_acc_id,
265                    "event_type": event_type,
266                    "trd_market": push_trd_market_str,
267                    "decode_status": decode_status,
268                    "body_base64": base64_encode_bytes(&push.body),
269                });
270                let deliveries = {
271                    let mut deliveries = Vec::with_capacity(subscribers.len());
272                    for (session_id, sub) in subscribers.iter() {
273                        // v1.4.106 codex 0932 F6 [P2]: trade decode-failed + restricted
274                        // key (allowed_acc_ids 非 None) → DROP. 不能让 restricted key
275                        // 看到无 acc gate 信息的 trade body 透传 (绕过 ACL).
276                        // unrestricted key (allowed_acc_ids None / 空) 仍透传带 decode_status="failed".
277                        if matches!(decode_result, TradePushDecode::DecodeFailed) {
278                            let restricted = sub
279                                .allowed_acc_ids_snapshot
280                                .as_ref()
281                                .map(|s| !s.is_empty())
282                                .unwrap_or(false);
283                            if restricted {
284                                let key_id = sub.owner_key_id.as_deref().unwrap_or("<none>");
285                                // 复用 cross-surface metric — reason="trade_decode_failed"
286                                futu_auth::metrics::bump_ws_filtered("trade_decode_failed", key_id);
287                                tracing::warn!(
288                                    proto_id = push.proto_id,
289                                    key_id,
290                                    "v1.4.106 audit 0932 F6: trade push body decode failed; \
291                                     dropped for restricted key (allowed_acc_ids set, \
292                                     cannot ACL-gate body without acc_id)"
293                                );
294                                continue;
295                            }
296                            // unrestricted: fall through, broadcast 带 decode_status="failed"
297                        }
298                        // v1.4.105 F5 fix: 改用 FilterRegistry::should_drop_event.
299                        // 行为对齐 4 surface — sub.acc_ids (MCP 显式订阅 list) 喂给
300                        // ctx.sub_state (REST sub-acc-push state 同语义); sub.allowed_*
301                        // _snapshot 喂给 ctx.allowed_* (caller key 限额).
302                        //
303                        // **行为微差** (与老 inline fn 一致, 不破老行为):
304                        // - sub.acc_ids 空 = MCP 老语义"无限制订阅" → 传 None
305                        //   (避免 REST sub_state 空集 tombstone 语义触发 drop-all)
306                        // - sub.acc_ids 非空 → 传 Some(&sub.acc_ids), 跟 REST 一致
307                        let sub_state_for_ctx = if sub.acc_ids.is_empty() {
308                            None
309                        } else {
310                            Some(&sub.acc_ids)
311                        };
312                        let ctx = futu_auth_pipeline::PushEventCtx {
313                            event_type,
314                            event_acc: push_acc_id,
315                            allowed_acc_ids: sub.allowed_acc_ids_snapshot.as_ref(),
316                            sub_state: sub_state_for_ctx,
317                            event_trd_market: push_trd_market_str,
318                            allowed_markets: sub.allowed_markets_snapshot.as_ref(),
319                        };
320                        if filter_registry.should_drop_event(&ctx) {
321                            // v1.4.105 T-C2 + F3 (codex review C4): bump filtered metric.
322                            // **统一 label 命名** 跟 4 surface 一致 — gRPC subscribe_push
323                            // / push_trd_acc / 都用 "trade_market" 标 Layer 3 (allowed_markets)
324                            // 拒. 老 "push.trade" 命名是 surface-specific (T-C2 sole), 改成
325                            // canonical "trade_market" 让跨 surface metrics jq aggregate 一致.
326                            let key_id = sub.owner_key_id.as_deref().unwrap_or("<none>");
327                            futu_auth::metrics::bump_ws_filtered("trade_market", key_id);
328                            continue;
329                        }
330                        deliveries.push(PushDelivery {
331                            peer: sub.peer.clone(),
332                            data: payload.clone(),
333                            session_id: session_id.clone(),
334                            owner_key_id: sub.owner_key_id.clone(),
335                            proto_id: push.proto_id,
336                        });
337                    }
338                    deliveries
339                };
340                for delivery in deliveries {
341                    tokio::spawn(async move {
342                        let params = rmcp::model::LoggingMessageNotificationParam {
343                            level: rmcp::model::LoggingLevel::Info,
344                            logger: Some("futu_push".to_string()),
345                            data: delivery.data,
346                        };
347                        if let Err(err) = delivery.peer.notify_logging_message(params).await {
348                            tracing::warn!(
349                                proto_id = delivery.proto_id,
350                                session_id = delivery.session_id,
351                                owner_key_id = delivery.owner_key_id.as_deref().unwrap_or("<none>"),
352                                error = %err,
353                                "mcp push notification send failed"
354                            );
355                        }
356                    });
357                }
358            }
359        });
360
361        // v1.4.39 Phase 5 stale cleanup: 5 分钟跑一次,移除 registered_at > 4h
362        // 的订阅者。避免长跑 daemon 累积陈旧 subscriber(客户端断开 /  rmcp
363        // session gone 但没显式 unregister 的情况)。
364        let subs_for_purge = Arc::downgrade(&self.push_subscribers);
365        tokio::spawn(async move {
366            use std::time::Duration;
367            const PURGE_INTERVAL: Duration = Duration::from_secs(5 * 60);
368            const MAX_AGE: Duration = Duration::from_secs(4 * 3600);
369            let mut ticker = tokio::time::interval(PURGE_INTERVAL);
370            ticker.tick().await; // skip the immediate first tick
371            loop {
372                ticker.tick().await;
373                let Some(subs_for_purge) = subs_for_purge.upgrade() else {
374                    break;
375                };
376                let now = std::time::Instant::now();
377                let mut subs = subs_for_purge.lock().await;
378                let before = subs.len();
379                subs.retain(|_, sub| {
380                    now.checked_duration_since(sub.registered_at)
381                        .map(|age| age < MAX_AGE)
382                        .unwrap_or(true)
383                });
384                let purged = before - subs.len();
385                if purged > 0 {
386                    tracing::info!(
387                        purged,
388                        remaining = subs.len(),
389                        max_age_secs = MAX_AGE.as_secs(),
390                        "v1.4.39 Phase 5: purged stale push subscribers (> 4h registered)"
391                    );
392                }
393            }
394        });
395
396        Ok(arc)
397    }
398}
399
400// ========== symbol 解析 ==========
401
402pub fn parse_symbol(s: &str) -> Result<Security> {
403    futu_qot::symbol::parse_symbol(s)
404}
405
406/// 格式化 Security 为 "MARKET.CODE"
407pub fn format_symbol(sec: &Security) -> String {
408    futu_qot::symbol::format_symbol(sec)
409}
410
411/// v1.4.90 P2-C: audit log Option<T> 序列化助手。
412///
413/// **背景**:之前 audit log 把 `Option<f64>` 用 `?req.price`(tracing 的 Debug
414/// shorthand)记录,渲染成 JSON 字符串 `"Some(400.0)"` / `"None"`,下游 jq /
415/// DuckDB 数值聚合炸(aggregator 期望 `400.0` number 或 `null`)。
416///
417/// **修法**:用 NaN sentinel 把 `Option<f64>` flatten 成 `f64`,tracing-subscriber
418/// 的 JSON formatter 内部走 `serde_json::Value::from(f64::NAN)` →
419/// `Number::from_f64(NaN) = None` → `Value::Null`。
420/// 整数 / 字符串同理(i32 → f64 NaN sentinel;&str → "" 哨兵)。
421///
422/// 验证依据:
423/// - `tracing_subscriber::fmt::format::json` line 501 `record_f64` 直接调
424///   `serde_json::Value::from(value)`
425/// - `serde_json::Value::from(f64)` impl: `Number::from_f64(f).map_or(Value::Null, Value::Number)`
426pub mod audit_fmt {
427    /// `Option<f64>` → `f64`(None → NaN)。tracing JSON 渲染 NaN 为 `null`。
428    #[inline]
429    pub fn opt_f64(v: Option<f64>) -> f64 {
430        v.unwrap_or(f64::NAN)
431    }
432
433    /// `Option<i32>` → `f64`(None → NaN,Some(n) → n as f64)。
434    /// i32 ≤ 2^31 < 2^52 mantissa,无精度损失。
435    #[inline]
436    pub fn opt_i32(v: Option<i32>) -> f64 {
437        v.map(f64::from).unwrap_or(f64::NAN)
438    }
439
440    /// `Option<&str>` → `&str`(None → "")。"" 哨兵在 audit 上下文里足以区分
441    /// 不传 vs 传空(因为 Symbol / owner 等业务字段不会是空字符串)。
442    #[inline]
443    pub fn opt_str(v: Option<&str>) -> &str {
444        v.unwrap_or("")
445    }
446}