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}