Skip to main content

futu_mcp/tools/
trade_unlock.rs

1//! MCP trade unlock tool.
2
3use rmcp::{RoleServer, handler::server::wrapper::Parameters, service::RequestContext};
4
5use crate::guard;
6use crate::tool_args::*;
7use crate::tool_auth::{http_bearer_token, outcome_key_id_from_snapshot};
8
9use super::FutuServer;
10
11pub(crate) fn validate_unlock_trade_acc_ids(acc_ids: Option<&[u64]>) -> Result<(), String> {
12    if let Some(ids) = acc_ids
13        && let Some((idx, _)) = ids.iter().enumerate().find(|(_, id)| **id == 0)
14    {
15        return Err(format!(
16            "futu_unlock_trade: acc_ids[{idx}] must be a positive non-zero acc_id; got 0"
17        ));
18    }
19    Ok(())
20}
21
22impl FutuServer {
23    async fn futu_unlock_trade_impl(
24        &self,
25        Parameters(req): Parameters<UnlockTradeReq>,
26        req_ctx: RequestContext<RoleServer>,
27    ) -> std::result::Result<String, String> {
28        let args_hash = guard::args_short_hash(&req);
29        tracing::warn!(
30            target: futu_auth::audit::TARGET,
31            iface = "mcp",
32            endpoint = "futu_unlock_trade",
33            unlock = req.unlock,
34            args_hash = %args_hash,
35            outcome = "request",
36            "unlock_trade request received"
37        );
38        // v1.4.103 codex F5.3 (P1) round 5: 切到 caller-specific scope check.
39        // 之前 require_tool_scope 只看 startup key, 受限 Bearer 仍可用 startup
40        // key 的 trade:unlock scope 解锁所有账户.
41        //
42        // v1.4.104 阶段 7-5: 走 pipeline 做 caller-specific scope check + expiry
43        // + audit. acc_ids 多元素白名单 enforcement 仍在本地 inline (special
44        // semantic: 多 acc_id loop + "no acc_ids + restriction" reject; pipeline
45        // 单一 explicit_acc_id 不足以覆盖).
46        //
47        // 流程:
48        //   1. resolve caller credential (Bearer → verify, fail-closed; 无
49        //      Bearer → fall back startup key PreVerified).
50        //   2. pipeline: scope=TradeUnlock + audit_emit=true + commit_rate=false.
51        //      Reject → return JSON error.
52        //   3. inline per-acc_id whitelist enforcement (本节专属语义).
53        let bearer_token = http_bearer_token(&req_ctx);
54        let credential: futu_auth_pipeline::Credential<'_> = match bearer_token
55            .as_deref()
56            .filter(|s| !s.is_empty())
57        {
58            Some(t) => match self.state.key_store().verify(t) {
59                Some(rec) => futu_auth_pipeline::Credential::PreVerified(rec),
60                None => {
61                    // codex F4 fail-closed: invalid Bearer → reject, 不 fall back
62                    futu_auth::audit::reject(
63                        "mcp",
64                        "futu_unlock_trade",
65                        "<bearer-invalid>",
66                        "invalid Bearer (v1.4.103 audit F5.3 fail-closed)",
67                    );
68                    return Err(serde_json::json!({
69                            "error": "futu_unlock_trade: invalid Bearer token (v1.4.103 audit F5.3 fail-closed)",
70                            "status": "error",
71                        })
72                        .to_string());
73                }
74            },
75            // v1.4.106 codex 0608 F2 (P1): startup fallback 用
76            // `get_by_id_for_current_machine` 替代裸 `get_by_id`, 让 SIGHUP 收紧
77            // allowed_machines 后能立即 reject (与 Bearer 路径 verify 行为对称).
78            None => match self
79                .state
80                .authed_key()
81                .and_then(|k| self.state.key_store().get_by_id_for_current_machine(&k.id))
82            {
83                Some(rec) => futu_auth_pipeline::Credential::PreVerified(rec),
84                None => futu_auth_pipeline::Credential::None,
85            },
86        };
87
88        let unlock_env = futu_auth_pipeline::AuthEnvelope {
89            surface: futu_auth_pipeline::SurfaceId::Mcp,
90            endpoint: futu_auth_pipeline::Endpoint::McpTool("futu_unlock_trade"),
91            needed_scope: Some(futu_auth::Scope::TradeUnlock),
92            credential,
93            proto_id: None,
94            body: &[],
95            explicit_acc_id: None, // multi-acc 白名单单独 inline enforce
96            explicit_ctx: None,
97            commit_rate: false, // unlock 不计 rate
98            audit_emit: true,
99        };
100        let caller_key_for_unlock = match futu_auth_pipeline::authenticate_request(
101            self.state.key_store(),
102            self.state.counters(),
103            unlock_env,
104        ) {
105            futu_auth_pipeline::AuthDecision::Allow { rec, .. } => rec,
106            futu_auth_pipeline::AuthDecision::Reject {
107                kind,
108                reason,
109                audit_key_id,
110            } => {
111                use futu_auth_pipeline::RejectKind;
112                let err_msg = match kind {
113                    RejectKind::Unauthenticated => {
114                        format!("futu_unlock_trade: API key required or expired ({reason})")
115                    }
116                    RejectKind::Forbidden => format!(
117                        "futu_unlock_trade: API key {audit_key_id:?} forbidden — needs trade:unlock scope ({reason})"
118                    ),
119                    _ => reason.clone(),
120                };
121                return Err(serde_json::json!({
122                    "error": err_msg,
123                    "status": "error",
124                })
125                .to_string());
126            }
127        };
128        if let Err(err) = validate_unlock_trade_acc_ids(req.acc_ids.as_deref()) {
129            return Err(serde_json::json!({
130                "error": err,
131                "status": "error",
132            })
133            .to_string());
134        }
135        // 若 caller 的 key 有 allowed_acc_ids 限制 + caller 显式传 acc_ids:
136        //   每个 acc_id 必须 ∈ allowed_acc_ids
137        // 若 caller 显式 acc_ids 为 None / empty + key 有限制:
138        //   reject (ambiguous — 不让"unlock all" silent 解锁未授权账户)
139        if let Some(ref rec) = caller_key_for_unlock
140            && let Some(ref allowed) = rec.allowed_acc_ids
141            && !allowed.is_empty()
142        {
143            match req.acc_ids.as_ref() {
144                Some(ids) if !ids.is_empty() => {
145                    for id in ids {
146                        if !allowed.contains(id) {
147                            futu_auth::audit::reject(
148                                "mcp",
149                                "futu_unlock_trade",
150                                &rec.id,
151                                &format!("acc_id {id} not in allowed list"),
152                            );
153                            return Err(serde_json::json!({
154                                "error": format!(
155                                    "futu_unlock_trade: API key {:?} not allowed to unlock acc_id {id} (allowed_acc_ids restriction)",
156                                    rec.id
157                                ),
158                                "status": "error",
159                            })
160                            .to_string());
161                        }
162                    }
163                }
164                _ => {
165                    // 没传 acc_ids + key 有限制 → 不允许 silent unlock all
166                    return Err(serde_json::json!({
167                        "error": format!(
168                            "futu_unlock_trade: API key {:?} has allowed_acc_ids restriction \
169                             but acc_ids not specified. Restricted keys must explicitly pass \
170                             acc_ids; unlock-all is rejected to prevent unauthorized broker \
171                             unlock side effects.",
172                            rec.id
173                        ),
174                        "status": "error",
175                        "hint": "pass acc_ids: [<your-allowed-acc-id>]",
176                    })
177                    .to_string());
178                }
179            }
180        }
181
182        // lock 不需要密码,unlock 要从账号级 keychain/env 读。
183        // MCP 只连接 gateway,不能从本进程可靠推断 daemon login account;
184        // 因此账号 hint 来自 `--trade-pwd-account` / FUTU_TRADE_PWD_ACCOUNT。
185        let pwd_md5 = if req.unlock {
186            match crate::trade_pwd::get_trade_password_md5_for_account(
187                self.state.trade_pwd_account(),
188            ) {
189                Ok(md5) => md5,
190                Err(e) => {
191                    let err = format!("unlock failed: {e}");
192                    tracing::warn!(
193                        target: futu_auth::audit::TARGET,
194                        iface = "mcp",
195                        endpoint = "futu_unlock_trade",
196                        outcome = "failure",
197                        reason = %err,
198                        "unlock failed: no password source"
199                    );
200                    return Self::tool_err(err);
201                }
202            }
203        } else {
204            String::new()
205        };
206
207        let client = self.client_or_err().await?;
208        let result = match futu_trd::account::unlock_trade(
209            &client,
210            &pwd_md5,
211            req.unlock,
212            req.otp.as_deref(),
213            req.security_firm,
214            req.acc_ids.clone().unwrap_or_default(),
215        )
216        .await
217        {
218            Ok(outcome) => {
219                if outcome.need_otp {
220                    // HIGH-2 修(code review):need_otp=true 是 unlock 失败的错误
221                    // 状态(用户需带 OTP 重试),应 set is_error=true 让 agent 通过
222                    // top-level envelope 感知,不需要 parse JSON `ok` 字段。
223                    // MED-NEW-1(2nd review):加 `error` field 让 emit_trade_outcome
224                    // 按 "failure" 记 audit log(之前 need_otp 会被错记 success)
225                    Err(serde_json::json!({
226                        "error": "unlock requires OTP (2FA token); pass otp= and retry",
227                        "need_otp": true,
228                        "message": outcome.message.unwrap_or_else(||
229                            "此账号开启了令牌动态密码(2FA)。\
230                             请重新调用 futu_unlock_trade 带 `otp` 参数(明文 OTP)".into()),
231                        "failed_accounts": outcome.failed_accounts,
232                        "status": "need_otp_retry",
233                    })
234                    .to_string())
235                } else if !req.unlock {
236                    Ok(
237                        serde_json::json!({ "ok": true, "message": "trade locked on gateway" })
238                            .to_string(),
239                    )
240                } else {
241                    let msg = if outcome.total_unlocked < outcome.total_requested {
242                        format!(
243                            "部分账户解锁成功({}/{})。\
244                             失败账户:{:?}(常见原因:品种权限未开通 / 影子子账户)",
245                            outcome.total_unlocked,
246                            outcome.total_requested,
247                            outcome.failed_accounts
248                        )
249                    } else {
250                        format!(
251                            "trade unlocked ({} accounts); cipher cached until gateway restarts",
252                            outcome.total_unlocked
253                        )
254                    };
255                    if outcome.total_unlocked > 0 {
256                        Ok(serde_json::json!({
257                            "ok": true,
258                            "need_otp": false,
259                            "total_requested": outcome.total_requested,
260                            "total_unlocked": outcome.total_unlocked,
261                            "failed_accounts": outcome.failed_accounts,
262                            "message": msg,
263                        })
264                        .to_string())
265                    } else {
266                        // MED-NEW-1(2nd review):总失败时加 `error` 让 audit log 按
267                        // failure 记录(不含则 emit_trade_outcome 错判 success)
268                        Err(serde_json::json!({
269                            "ok": false,
270                            "error": "all accounts failed to unlock",
271                            "need_otp": false,
272                            "total_requested": outcome.total_requested,
273                            "total_unlocked": outcome.total_unlocked,
274                            "failed_accounts": outcome.failed_accounts,
275                            "message": msg,
276                        })
277                        .to_string())
278                    }
279                }
280            }
281            Err(e) => Err(format!("unlock_trade RPC failed: {e}")),
282        };
283        // codex round 1 F5 (P2) v1.4.105 + v1.4.106 F4 alignment:
284        // emit_trade_outcome 用 caller_key_for_unlock 的 key id (pipeline Allow
285        // 时的 KeyRecord), **不**调 self.current_key_id(None) 重新 verify —
286        // 防 HTTP per-request Bearer 调 unlock 时 outcome 被错归 startup key
287        // (F5 root cause), 同时也防 SIGHUP race (F4 同模式 — dispatch 中途
288        // SIGHUP revoke caller key, snapshot 仍 hold 住).
289        //
290        // legacy mode (caller_key_for_unlock = None, scope mode 关闭) → 退化
291        // 到 startup authed_key (与 v1.4.103 兼容). authed_key 取 precheck 时
292        // 的 ref (snapshot), 同样不重新查 KeyStore.
293        let startup_key = self.state.authed_key();
294        let outcome_key_id =
295            outcome_key_id_from_snapshot(caller_key_for_unlock.as_ref(), startup_key.as_ref());
296        guard::emit_trade_outcome(
297            "futu_unlock_trade",
298            outcome_key_id,
299            &args_hash,
300            Self::result_as_str(&result),
301        );
302        result
303    }
304}
305
306include!(concat!(
307    env!("OUT_DIR"),
308    "/generated_mcp_routes_trade_unlock.rs"
309));