Skip to main content

futu_mcp/tools/
trade_unlock.rs

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