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::{
9    RoleServer, handler::server::wrapper::Parameters, service::RequestContext, tool, tool_router,
10};
11
12use super::{FutuServer, system::json_tool_output};
13
14#[tool_router(router = subscription_tool_router, vis = "pub(crate)")]
15impl FutuServer {
16    #[tool(
17        description = "Query current subscription state (subscribed types, quota used/remaining). Python SDK: OpenQuoteContext.query_subscription."
18    )]
19    async fn futu_query_subscription(
20        &self,
21        Parameters(req): Parameters<QuerySubscriptionReq>,
22        req_ctx: RequestContext<RoleServer>,
23    ) -> std::result::Result<String, String> {
24        tracing::info!(tool = "futu_query_subscription");
25        let client = self
26            .read_client_or_err("futu_query_subscription", &req_ctx, None, None)
27            .await?;
28        Self::wrap_result(handlers::core::query_subscription(&client, req.is_req_all_conn).await)
29    }
30
31    #[tool(
32        description = "Get current daemon used quota counters: subscribed quote quota and historical K-line quota. Python SDK: OpenQuoteContext.get_used_quota."
33    )]
34    async fn futu_get_used_quota(
35        &self,
36        Parameters(_req): Parameters<NoArgs>,
37        req_ctx: RequestContext<RoleServer>,
38    ) -> std::result::Result<String, String> {
39        tracing::info!(tool = "futu_get_used_quota");
40        let client = self
41            .read_client_or_err("futu_get_used_quota", &req_ctx, None, None)
42            .await?;
43        Self::wrap_result(handlers::core::get_used_quota(&client).await)
44    }
45
46    /// v1.4.74 A1 BUG-011 fix: 加 `futu_subscribe` 补齐 MCP 对称性。
47    ///
48    /// MCP 之前有 `futu_unsubscribe` / `futu_sub_acc_push` / `futu_unsub_acc_push`
49    /// / `futu_query_subscription` / `futu_push_subscriber_info` 但缺
50    /// `futu_subscribe` 本身 → API 对称性被打破("能取消但不能订阅")。
51    ///
52    /// 架构:本 tool 触发 gateway 层订阅(CMD 3001 QOT_SUB),不返 push stream
53    /// 本身。Push 数据通过 SSE notification 走(v1.4.58 MCP SSE basics),
54    /// 客户端用 `futu_push_subscriber_info` 查询订阅状态。
55    ///
56    /// 对齐 Python SDK `OpenQuoteContext.subscribe`。
57    #[tool(
58        description = "Subscribe market data for given symbols + sub_types. Push data arrives via SSE notifications (HTTP mode). Python SDK: OpenQuoteContext.subscribe."
59    )]
60    async fn futu_subscribe(
61        &self,
62        Parameters(req): Parameters<SubscribeReq>,
63        req_ctx: RequestContext<RoleServer>,
64    ) -> std::result::Result<String, String> {
65        req.validate()?;
66        tracing::info!(
67            tool = "futu_subscribe",
68            symbols = ?req.symbols,
69            sub_types = ?req.sub_types,
70            is_first_push = req.is_first_push,
71            is_reg_push = req.is_reg_push,
72            extended_time = ?req.extended_time,
73            session = ?req.session,
74            is_sub_order_book_detail = ?req.is_sub_order_book_detail,
75        );
76        let client = self
77            .read_client_or_err("futu_subscribe", &req_ctx, None, None)
78            .await?;
79        Self::wrap_result(
80            handlers::core::subscribe(
81                &client,
82                &req.symbols,
83                &req.sub_types,
84                req.is_first_push,
85                req.is_reg_push,
86                req.extended_time,
87                req.session,
88                req.is_sub_order_book_detail,
89            )
90            .await,
91        )
92    }
93
94    #[tool(
95        description = "Unsubscribe market data (by symbol+type, or unsub_all to clear this connection). Python SDK: OpenQuoteContext.unsubscribe / unsubscribe_all."
96    )]
97    async fn futu_unsubscribe(
98        &self,
99        Parameters(req): Parameters<UnsubscribeReq>,
100        req_ctx: RequestContext<RoleServer>,
101    ) -> std::result::Result<String, String> {
102        tracing::info!(
103            tool = "futu_unsubscribe",
104            count = req.symbols.len(),
105            unsub_all = req.unsub_all
106        );
107        let client = self
108            .read_client_or_err("futu_unsubscribe", &req_ctx, None, None)
109            .await?;
110        Self::wrap_result(
111            handlers::core::unsubscribe(&client, &req.symbols, &req.sub_types, req.unsub_all).await,
112        )
113    }
114
115    #[tool(
116        description = "Subscribe account order / deal push for given trading accounts. HTTP-mode MCP clients receive pushes as LoggingMessage notifications with {kind, proto_id, body_base64}. Payload body is raw Futu protobuf; decode client-side. Python SDK: OpenTradeContext.sub_acc_push."
117    )]
118    async fn futu_sub_acc_push(
119        &self,
120        Parameters(req): Parameters<SubAccPushReq>,
121        req_ctx: RequestContext<RoleServer>,
122    ) -> std::result::Result<String, String> {
123        // v1.4.102 codex 47 F2 (P1): 空 acc_ids 必拒. 之前空 list 进
124        // register_push_subscriber, subscriber_should_receive 把空 set 视为
125        // "subscribe-all" 全开 push, agent 调 {"acc_ids": []} 等于全订阅
126        // (silent privilege escalation 反模式 D / pitfall #45).
127        if req.acc_ids.is_empty() {
128            return Err(
129                "futu_sub_acc_push: acc_ids 必填非空. 之前空 list silent 全订阅 \
130                 (v1.4.102 codex 47 F2 P1 fix). 调 futu_list_accounts 看可用 \
131                 acc_id, 显式列出要订阅的账户."
132                    .to_string(),
133            );
134        }
135
136        // v1.4.103 (codex 50 F4 / 52 F4 / 54 F3 / 58 F2 — B6): 在调 daemon
137        // 之前用 caller's KeyRecord 比对每个 req.acc_ids ⊆ allowed_acc_ids.
138        // 之前 register-after-daemon-success 只防 daemon 失败的 race window,
139        // 不防 narrow-scope key 让 daemon 全局订阅 acc B (subscriber 本身被
140        // delivery filter 过滤掉, 但 daemon 已对 acc B 发起订阅副作用).
141        //
142        // v1.4.104 codex F3 (P2): 用 pipeline 返的 snapshot 做 ownership +
143        // visibility — pipeline 授权 + push 注册同一 KeyRecord 实例, 避免
144        // SIGHUP 之间 drift / fail-open. 取第 1 个 acc_id 的 snapshot 作 owner;
145        // 后续 acc_ids 通过 pipeline 复 check (不重 capture, 同一 caller).
146        let mut snap_opt: Option<CallerSnapshot> = None;
147        for (idx, acc_id) in req.acc_ids.iter().enumerate() {
148            let snap = self.require_acc_read_with_acc_id(
149                "futu_sub_acc_push",
150                &req_ctx,
151                req.api_key.as_deref(),
152                Some(*acc_id),
153            )?;
154            if idx == 0 {
155                snap_opt = Some(snap);
156            }
157        }
158        let Some(snap) = snap_opt else {
159            return Err(serde_json::json!({
160                "error": "futu_sub_acc_push: caller snapshot resolution failed after acc_ids validation",
161                "status": "error",
162                "hint": "retry after refreshing API key state; if it persists, check auth pipeline logs",
163            })
164            .to_string());
165        };
166
167        tracing::info!(tool = "futu_sub_acc_push", count = req.acc_ids.len());
168
169        let client = self.client_or_err().await?;
170
171        // v1.4.102 codex 47 F3 / 48 F3 (P2): register-after-daemon-success.
172        // 之前先 register peer 后调 daemon, daemon 失败时 local subscriber 留下
173        // 来 — 后续如果其他 caller 让 daemon sub 了同 acc 流, 这个本来失败的
174        // session 仍能收 push (race window leak). 现在: 先调 daemon, 成功才
175        // register local subscriber; daemon 失败 → 不留 local state.
176        let daemon_resp = handlers::trade::sub_acc_push(&client, &req.acc_ids).await;
177        match daemon_resp {
178            Ok(_) => {
179                // v1.4.104 codex F3 (P2): 用 pipeline snapshot 不重新 resolve.
180                // 与 v1.4.103 codex F5.7 (P2) 防 SIGHUP race 行为对齐, 但现
181                // owner_key_id + bearer_token 都从 snapshot 直取, 与 pipeline
182                // 授权决策同一身份. 不再 await 后重 verify.
183                let owner_key_id = snap.key_id.clone();
184                let bearer_token = snap.bearer_token.clone();
185                let acc_ids_set: std::collections::HashSet<u64> =
186                    req.acc_ids.iter().copied().collect();
187                let session_id = self
188                    .state
189                    .register_push_subscriber_with_owner(
190                        req_ctx.peer.clone(),
191                        acc_ids_set,
192                        bearer_token,
193                        owner_key_id,
194                    )
195                    .await;
196                tracing::info!(
197                    tool = "futu_sub_acc_push",
198                    session_id = %session_id,
199                    count = req.acc_ids.len(),
200                    "v1.4.102 audit 47 F3: push subscriber registered after daemon success"
201                );
202                json_tool_output(
203                    "futu_sub_acc_push",
204                    &serde_json::json!({
205                        "ok": true,
206                        "subscribed_acc_ids": req.acc_ids,
207                        "session_id": session_id,
208                        "unsub_hint": format!(
209                            "call `futu_unsub_acc_push` with session_id=\"{session_id}\" to stop receiving pushes; otherwise auto-purged after 4h"
210                        ),
211                    }),
212                )
213            }
214            Err(e) => Self::tool_err(format!("futu_sub_acc_push: {e}")),
215        }
216    }
217
218    #[tool(
219        description = "Unsubscribe from account push notifications. Pass session_id from previous futu_sub_acc_push response (session_id field or unsub_hint). Returns {removed_count}. If session_id is not found, removed_count=0 (likely auto-purged or never registered)."
220    )]
221    async fn futu_unsub_acc_push(
222        &self,
223        Parameters(req): Parameters<UnsubAccPushReq>,
224        req_ctx: RequestContext<RoleServer>,
225    ) -> std::result::Result<String, String> {
226        // v1.4.103 codex F4 (P1) fail-closed: scope check 用 caller-specific
227        // (HTTP Bearer / api_key 优先) 而不是 process-wide startup key.
228        // 之前 require_tool_scope 只看 startup, 受限 Bearer fall through.
229        let snap = self.require_acc_read_with_acc_id(
230            "futu_unsub_acc_push",
231            &req_ctx,
232            req.api_key.as_deref(),
233            None,
234        )?;
235        let Some(ref session_id) = req.session_id else {
236            return Self::tool_err(
237                "session_id required. Get it from a previous futu_sub_acc_push response.",
238            );
239        };
240        // v1.4.103 (codex 50 F6 / 53 F4 — B8): unsub session ownership check.
241        // 之前任何 caller 拿到可见 session_id 即可 remove, 跨 caller 容易踩
242        // (尤其 push_subscriber_info 列其他 session 后被恶意 caller unsub).
243        // 现在: 解析 caller key (Bearer / startup), 比对 subscriber.owner_key_id.
244        // v1.4.103 codex F4 (P1) fail-closed: invalid Bearer → 不 fall back
245        // startup key. 因为前面 require_acc_read_with_acc_id 已经把 invalid
246        // api_key / Bearer reject 掉了, 走到这里直接复用同一 auth snapshot 的
247        // key_id 做 ownership check,避免重新解析时与 sub/register 身份漂移。
248        let caller_key_id = snap.key_id;
249
250        let removed = self
251            .state
252            .unregister_push_subscriber_with_owner_check(
253                session_id.as_str(),
254                caller_key_id.as_deref(),
255            )
256            .await;
257        tracing::info!(
258            tool = "futu_unsub_acc_push",
259            removed = removed.is_ok(),
260            session_id = %session_id,
261            caller_key_id = ?caller_key_id,
262            "v1.4.103 B8: push subscriber unregister with ownership check"
263        );
264        match removed {
265            Ok(true) => json_tool_output("futu_unsub_acc_push", &serde_json::json!({
266                "ok": true,
267                "removed_count": 1,
268                "session_id": session_id,
269            })),
270            Ok(false) => json_tool_output("futu_unsub_acc_push", &serde_json::json!({
271                "ok": true,
272                "removed_count": 0,
273                "session_id": session_id,
274                "hint": "session_id not found (likely 4h auto-purged or never registered)",
275            })),
276            Err(reason) => Err(serde_json::json!({
277                "error": format!("ownership check failed: {reason}"),
278                "status": "error",
279                "hint": "only the original caller (matched by API key id) or an admin-scope key can unsub a session.",
280            })
281            .to_string()),
282        }
283    }
284
285    #[tool(
286        description = "Diagnostic — list active push subscriptions on this MCP server. Returns {total_count, subscriptions: [{session_id, acc_ids, age_secs}]}. Useful to verify whether futu_sub_acc_push registered, check auto-purge timing, or debug missing pushes."
287    )]
288    async fn futu_push_subscriber_info(
289        &self,
290        Parameters(_req): Parameters<NoArgs>,
291        req_ctx: RequestContext<RoleServer>,
292    ) -> std::result::Result<String, String> {
293        // v1.4.103 codex F5 (P2): 加 RequestContext 让 caller-specific Bearer
294        // 解析能生效. 之前 require_tool_scope 只看 startup key, narrow Bearer
295        // caller 看到的 caller_allowed 是 startup key 的, 跨租户泄漏其他
296        // agent 的订阅 acc_ids.
297        //
298        // v1.4.104 codex F3 (P2) fix: 用 pipeline 返的 snapshot 做 visibility
299        // filter, 不再 re-resolve from Bearer/startup (避免 SIGHUP race —
300        // pipeline 授权与 visibility 用同一 KeyRecord 实例).
301        let snap =
302            self.require_acc_read_with_acc_id("futu_push_subscriber_info", &req_ctx, None, None)?;
303        let subs = self
304            .state
305            .push_subscribers_summary(snap.allowed_acc_ids.as_ref())
306            .await;
307        json_tool_output(
308            "futu_push_subscriber_info",
309            &serde_json::json!({
310                "ok": true,
311                "total_count": subs.len(),
312                "subscriptions": subs.iter().map(|(sid, acc_ids, age)| serde_json::json!({
313                    "session_id": sid,
314                    "acc_ids": acc_ids.iter().collect::<Vec<_>>(),
315                    "age_secs": age,
316                })).collect::<Vec<_>>(),
317            }),
318        )
319    }
320}