Skip to main content

futu_mcp/tools/
subscription.rs

1//! MCP subscription tools (QOT subscribe/unsubscribe and trade push subscriber state).
2
3use 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    /// v1.4.74 A1 BUG-011 fix: 加 `futu_subscribe` 补齐 MCP 对称性。
38    ///
39    /// MCP 之前有 `futu_unsubscribe` / `futu_sub_acc_push` / `futu_unsub_acc_push`
40    /// / `futu_query_subscription` / `futu_push_subscriber_info` 但缺
41    /// `futu_subscribe` 本身 → API 对称性被打破("能取消但不能订阅")。
42    ///
43    /// 架构:本 tool 触发 gateway 层订阅(CMD 3001 QOT_SUB),不返 push stream
44    /// 本身。Push 数据通过 SSE notification 走(v1.4.58 MCP SSE basics),
45    /// 客户端用 `futu_push_subscriber_info` 查询订阅状态。
46    ///
47    /// 对齐 Python SDK `OpenQuoteContext.subscribe`。
48    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        // v1.4.102 codex 47 F2 (P1): 空 acc_ids 必拒. 之前空 list 进
106        // register_push_subscriber, subscriber_should_receive 把空 set 视为
107        // "subscribe-all" 全开 push, agent 调 {"acc_ids": []} 等于全订阅
108        // (silent privilege escalation 反模式 D / pitfall #45).
109        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        // v1.4.103 (codex 50 F4 / 52 F4 / 54 F3 / 58 F2 — B6): 在调 daemon
119        // 之前用 caller's KeyRecord 比对每个 req.acc_ids ⊆ allowed_acc_ids.
120        // 之前 register-after-daemon-success 只防 daemon 失败的 race window,
121        // 不防 narrow-scope key 让 daemon 全局订阅 acc B (subscriber 本身被
122        // delivery filter 过滤掉, 但 daemon 已对 acc B 发起订阅副作用).
123        //
124        // v1.4.104 codex F3 (P2): 用 pipeline 返的 snapshot 做 ownership +
125        // visibility — pipeline 授权 + push 注册同一 KeyRecord 实例, 避免
126        // SIGHUP 之间 drift / fail-open. 取第 1 个 acc_id 的 snapshot 作 owner;
127        // 后续 acc_ids 通过 pipeline 复 check (不重 capture, 同一 caller).
128        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        // Modern resources retain a FIFO for their lifetime. Reserve the global
169        // slot before connecting or dispatching to the daemon so capacity reject
170        // cannot create a remote orphan subscription.
171        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        // v1.4.102 codex 47 F3 / 48 F3 (P2): register-after-daemon-success.
182        // 之前先 register peer 后调 daemon, daemon 失败时 local subscriber 留下
183        // 来 — 后续如果其他 caller 让 daemon sub 了同 acc 流, 这个本来失败的
184        // session 仍能收 push (race window leak). 现在: 先调 daemon, 成功才
185        // register local subscriber; daemon 失败 → 不留 local state.
186        let daemon_resp = handlers::trade::sub_acc_push(&client, &req.acc_ids).await;
187        match daemon_resp {
188            Ok(_) => {
189                // v1.4.104 codex F3 (P2): 用 pipeline snapshot 不重新 resolve.
190                // 与 v1.4.103 codex F5.7 (P2) 防 SIGHUP race 行为对齐:owner
191                // 和 acc/market scope 都从同一 snapshot 直取,不在 await 后
192                // 重查身份。
193                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        // v1.4.103 codex F4 (P1) fail-closed: scope check 用 caller-specific
260        // (HTTP Bearer / api_key 优先) 而不是 process-wide startup key.
261        // 之前 require_tool_scope 只看 startup, 受限 Bearer fall through.
262        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        // v1.4.103 (codex 50 F6 / 53 F4 — B8): unsub session ownership check.
274        // 之前任何 caller 拿到可见 session_id 即可 remove, 跨 caller 容易踩
275        // (尤其 push_subscriber_info 列其他 session 后被恶意 caller unsub).
276        // 现在: 解析 caller key (Bearer / startup), 比对 subscriber.owner_key_id.
277        // v1.4.103 codex F4 (P1) fail-closed: invalid Bearer → 不 fall back
278        // startup key. 因为前面 require_acc_read_with_acc_id 已经把 invalid
279        // api_key / Bearer reject 掉了, 走到这里直接复用同一 auth snapshot 的
280        // key_id 做 ownership check,避免重新解析时与 sub/register 身份漂移。
281        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        // v1.4.103 codex F5 (P2): 加 RequestContext 让 caller-specific Bearer
335        // 解析能生效. 之前 require_tool_scope 只看 startup key, narrow Bearer
336        // caller 看到的 caller_allowed 是 startup key 的, 跨租户泄漏其他
337        // agent 的订阅 acc_ids.
338        //
339        // v1.4.104 codex F3 (P2) fix: 用 pipeline 返的 snapshot 做 visibility
340        // filter, 不再 re-resolve from Bearer/startup (避免 SIGHUP race —
341        // pipeline 授权与 visibility 用同一 KeyRecord 实例).
342        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));