Skip to main content

futu_mcp/tools/
trade_write.rs

1//! MCP trade-write tools (place/modify/cancel/reconfirm/unlock).
2
3use futu_auth::CheckCtx;
4use rmcp::{
5    RoleServer, handler::server::wrapper::Parameters, service::RequestContext, tool, tool_router,
6};
7
8use crate::guard;
9use crate::handlers;
10use crate::tool_args::*;
11use crate::tool_auth::{http_bearer_token, outcome_key_id_from_snapshot};
12
13use super::FutuServer;
14
15pub(crate) fn validate_unlock_trade_acc_ids(acc_ids: Option<&[u64]>) -> Result<(), String> {
16    if let Some(ids) = acc_ids
17        && let Some((idx, _)) = ids.iter().enumerate().find(|(_, id)| **id == 0)
18    {
19        return Err(format!(
20            "futu_unlock_trade: acc_ids[{idx}] must be a positive non-zero acc_id; got 0"
21        ));
22    }
23    Ok(())
24}
25
26#[tool_router(router = trade_write_tool_router, vis = "pub(crate)")]
27impl FutuServer {
28    // ------- 交易写入(需 --enable-trading) -------
29
30    #[tool(
31        description = "⚠️ REAL MONEY when env=real. Place an order on a live brokerage account. REQUIRES futu-mcp started with --enable-trading; real env additionally requires --allow-real-trading; gateway must have been unlocked via `futu_unlock_trade` first. **Market hours requirement**: OpenD does NOT pre-submit orders during closed hours — call during active session (HK 09:30-16:00 HKT, US 09:30-16:00 ET, etc.). Off-hours: use Futu/moomoo mobile APP (separate queue), not this tool. Changing `order_type` (AUCTION / fill_outside_rth) does NOT bypass this — server refuses identically."
32    )]
33    async fn futu_place_order(
34        &self,
35        Parameters(req): Parameters<PlaceOrderReq>,
36        req_ctx: RequestContext<RoleServer>,
37    ) -> std::result::Result<String, String> {
38        // per-call key 优先级:tool args `api_key` > HTTP Authorization Bearer > startup key
39        let header_token = http_bearer_token(&req_ctx);
40        let override_key = req.api_key.as_deref().or(header_token.as_deref());
41        let args_hash = guard::args_short_hash(&req);
42        tracing::warn!(
43            target: futu_auth::audit::TARGET,
44            iface = "mcp",
45            endpoint = "futu_place_order",
46            env = %req.env,
47            market = %req.market,
48            acc_id = req.acc_id.unwrap_or(0), // v1.4.105 T-D1: Option<u64> → u64 for audit log
49            card_num_provided = req.card_num.is_some(),
50            side = %req.side,
51            order_type = %req.order_type,
52            code = %req.code,
53            qty = req.qty,
54            // v1.4.90 P2-C: Option<f64> → f64 NaN sentinel; was `?req.price`
55            // 之前 record_debug 把 Some(400.0) 编为 string "Some(400.0)" 让下游 jq 数值聚合炸
56            price = crate::state::audit_fmt::opt_f64(req.price),
57            args_hash = %args_hash,
58            outcome = "request",
59            "place_order request received"
60        );
61        // codex round 1 F2 (P2) v1.4.105: caller key strict pre-check FIRST,
62        // **再** client_or_err + resolve. invalid Bearer 在此 fail-closed,
63        // 不再触发 daemon GetAccList + startup-key allowed_card_nums leak.
64        let caller_key_rec = match self.require_caller_key_strict("futu_place_order", override_key)
65        {
66            Ok(rec) => rec,
67            Err(reject_json) => return Err(reject_json),
68        };
69        // codex round 2 F1 (P2) v1.4.105: 早期 trade-scope 校验 — 在
70        // `client_or_err` + `resolve_acc_id_with_card_num` 之前. 防 valid
71        // 但**非-trade** key (e.g. qot:read only) 触发 daemon GetAccList +
72        // card_num 探测 not-found/ambiguous/existence timing.
73        if let Some(reject) =
74            self.require_trading_scope_only("futu_place_order", &req.env, caller_key_rec.as_ref())
75        {
76            return Err(reject);
77        }
78        // v1.4.105 D12 (Phase 2): resolve card_num → acc_id 在 scope check 前.
79        // require_trading 的 CheckCtx 需要 final acc_id 做 allowed_acc_ids 校验,
80        // 所以必须 client_or_err + resolve 提前. card_num resolve 多花 1 次
81        // GetAccList RPC (~10ms 本地 daemon).
82        let client = self.client_or_err().await?;
83        // v1.4.105 D12 contract-hardening 补丁: 同步传 allowed_card_nums 做 string-level
84        // whitelist 校验 (resolve 前). caller key 配置非空时, user 输 card_num
85        // 必须 ∈ 白名单 — UX clear.
86        // codex F2: 用 caller_key_rec (require_caller_key_strict 已验) 而非
87        // current_key_rec(override_key) 重新查 — 避免 silent fallback startup.
88        let allowed_card_nums = caller_key_rec
89            .as_ref()
90            .and_then(|r| r.allowed_card_nums.as_deref());
91        // v1.4.106 codex round 2 F1 case 2 (P1) fix: caller-snapshot acc_id
92        // 早过滤 — 受限 key 不再能 enumerate 其他用户的 acc_id (timing leak
93        // via 0-match/1-match/N-match 差异).
94        let caller_allowed_acc_ids = caller_key_rec
95            .as_ref()
96            .and_then(|r| r.allowed_acc_ids.as_ref());
97        let resolved_acc_id = match handlers::trade_write::resolve_acc_id_with_card_num(
98            &client,
99            req.acc_id.unwrap_or(0),
100            req.card_num.as_deref(),
101            allowed_card_nums,
102            caller_allowed_acc_ids,
103        )
104        .await
105        {
106            Ok(id) => id,
107            Err(msg) => return Self::tool_err(msg),
108        };
109        let ctx = CheckCtx {
110            market: req.market.trim().to_ascii_uppercase(),
111            symbol: format!(
112                "{}.{}",
113                req.market.trim().to_ascii_uppercase(),
114                req.code.trim()
115            ),
116            order_value: req.price.map(|p| p * req.qty),
117            trd_side: Some(req.side.trim().to_ascii_uppercase()),
118            acc_id: Some(resolved_acc_id), // v1.4.35; v1.4.105 D12: resolved
119            mutation_no_exposure: false,
120            currency: None,
121        };
122        if let Some(rej) =
123            self.require_trading("futu_place_order", &req.env, Some(ctx), override_key)
124        {
125            // MED-2: scope 拒绝 → Err(rmcp set is_error=true)
126            return Err(rej);
127        }
128        let result = Self::wrap_result(
129            handlers::trade_write::place_order(
130                &client,
131                handlers::trade_write::PlaceOrderInput {
132                    env: &req.env,
133                    acc_id: resolved_acc_id,
134                    market: &req.market,
135                    side: &req.side,
136                    order_type: &req.order_type,
137                    code: &req.code,
138                    qty: req.qty,
139                    price: req.price,
140                    idempotency_key: req.idempotency_key.clone(),
141                    // v1.4.53 F1 条件单
142                    stop_price: req.stop_price,
143                    trail_type: req.trail_type,
144                    trail_value: req.trail_value,
145                    trail_spread: req.trail_spread,
146                },
147            )
148            .await,
149        );
150        // codex round 1 F4 (P2) v1.4.106: emit_trade_outcome 用 caller_key_rec
151        // snapshot (require_caller_key_strict 已 lock 住), **不**调
152        // current_key_id(override_key) 重新 verify — 防 SIGHUP reload 在
153        // daemon dispatch 中途 revoke/narrow per-call key, audit 记录被误归
154        // 属到 startup key 或 fallback. snapshot reuse pattern 与 unlock_trade
155        // (codex round 1 F5) / list_accounts (codex round 1 F3) 一致.
156        let outcome_key_id =
157            outcome_key_id_from_snapshot(caller_key_rec.as_ref(), self.state.authed_key.as_ref());
158        guard::emit_trade_outcome(
159            "futu_place_order",
160            outcome_key_id,
161            &args_hash,
162            Self::result_as_str(&result),
163        );
164        result
165    }
166
167    #[tool(
168        description = "⚠️ REAL MONEY when env=real. Modify an existing live order (change qty/price, cancel, disable/enable/delete). REQUIRES --enable-trading; real env needs --allow-real-trading. For simple cancel, prefer `futu_cancel_order`. **Market hours requirement**: same as `futu_place_order` — off-hours hit server-side refusal regardless of `op`."
169    )]
170    async fn futu_modify_order(
171        &self,
172        Parameters(req): Parameters<ModifyOrderReq>,
173        req_ctx: RequestContext<RoleServer>,
174    ) -> std::result::Result<String, String> {
175        let header_token = http_bearer_token(&req_ctx);
176        let override_key = req.api_key.as_deref().or(header_token.as_deref());
177        let args_hash = guard::args_short_hash(&req);
178        tracing::warn!(
179            target: futu_auth::audit::TARGET,
180            iface = "mcp",
181            endpoint = "futu_modify_order",
182            env = %req.env,
183            market = %req.market,
184            acc_id = req.acc_id.unwrap_or(0), // v1.4.105 T-D1: Option<u64> → u64 for audit log
185            card_num_provided = req.card_num.is_some(),
186            order_id = %req.order_id,
187            op = %req.op,
188            // v1.4.90 P2-C: Option<f64> → f64 NaN sentinel
189            qty = crate::state::audit_fmt::opt_f64(req.qty),
190            price = crate::state::audit_fmt::opt_f64(req.price),
191            args_hash = %args_hash,
192            outcome = "request",
193            "modify_order request received"
194        );
195        // codex round 1 F2 (P2) v1.4.105: caller key strict pre-check FIRST.
196        let caller_key_rec = match self.require_caller_key_strict("futu_modify_order", override_key)
197        {
198            Ok(rec) => rec,
199            Err(reject_json) => return Err(reject_json),
200        };
201        // codex round 2 F1 (P2) v1.4.105: 早期 trade-scope 校验 (同 place_order).
202        if let Some(reject) =
203            self.require_trading_scope_only("futu_modify_order", &req.env, caller_key_rec.as_ref())
204        {
205            return Err(reject);
206        }
207        // v1.4.105 D12: client_or_err + resolve card_num → acc_id 提前 (scope check 用)
208        let client = self.client_or_err().await?;
209        // v1.4.105 D12 contract-hardening 补丁: 同步传 allowed_card_nums 做 string-level
210        // whitelist 校验 (resolve 前). caller key 配置非空时, user 输 card_num
211        // 必须 ∈ 白名单 — UX clear.
212        // codex F2: 用 caller_key_rec (require_caller_key_strict 已验) 而非
213        // current_key_rec(override_key) 重新查.
214        let allowed_card_nums = caller_key_rec
215            .as_ref()
216            .and_then(|r| r.allowed_card_nums.as_deref());
217        // v1.4.106 codex round 2 F1 case 2 (P1) fix: caller-snapshot acc_id
218        // 早过滤 (同 place_order).
219        let caller_allowed_acc_ids = caller_key_rec
220            .as_ref()
221            .and_then(|r| r.allowed_acc_ids.as_ref());
222        let resolved_acc_id = match handlers::trade_write::resolve_acc_id_with_card_num(
223            &client,
224            req.acc_id.unwrap_or(0),
225            req.card_num.as_deref(),
226            allowed_card_nums,
227            caller_allowed_acc_ids,
228        )
229        .await
230        {
231            Ok(id) => id,
232            Err(msg) => return Self::tool_err(msg),
233        };
234        // modify 改单 API 只给 order_id,symbol/金额/side 都推不出来 → 填空 ctx
235        // 好让 market 白名单 / 时段 / 速率限制照样生效(symbol / 单笔 / 日累计 / side
236        // 这几项在 limits.rs 里都已 skip-empty-ctx)
237        let mutation_ctx = CheckCtx {
238            market: req.market.trim().to_ascii_uppercase(),
239            symbol: String::new(),
240            order_value: None,
241            trd_side: None,
242            acc_id: Some(resolved_acc_id), // v1.4.35; v1.4.105 D12: resolved
243            mutation_no_exposure: false,
244            currency: None,
245        };
246        if let Some(rej) = self.require_trading(
247            "futu_modify_order",
248            &req.env,
249            Some(mutation_ctx),
250            override_key,
251        ) {
252            // MED-2: scope 拒绝 → Err(rmcp set is_error=true)
253            return Err(rej);
254        }
255        let result = Self::wrap_result(
256            handlers::trade_write::modify_order(
257                &client,
258                handlers::trade_write::ModifyOrderInput {
259                    env: &req.env,
260                    acc_id: resolved_acc_id,
261                    market: &req.market,
262                    order_id: &req.order_id,
263                    op: &req.op,
264                    qty: req.qty,
265                    price: req.price,
266                    idempotency_key: req.idempotency_key.clone(),
267                },
268            )
269            .await,
270        );
271        // codex round 1 F4 (P2) v1.4.106: snapshot reuse — 见 place_order 同段注释.
272        let outcome_key_id =
273            outcome_key_id_from_snapshot(caller_key_rec.as_ref(), self.state.authed_key.as_ref());
274        guard::emit_trade_outcome(
275            "futu_modify_order",
276            outcome_key_id,
277            &args_hash,
278            Self::result_as_str(&result),
279        );
280        result
281    }
282
283    #[tool(
284        description = "⚠️ REAL MONEY when env=real. Cancel a live order by order_id. REQUIRES --enable-trading; real env needs --allow-real-trading. Convenience wrapper over `futu_modify_order` with op=CANCEL. Same market-hours requirement as `futu_place_order`."
285    )]
286    async fn futu_cancel_order(
287        &self,
288        Parameters(req): Parameters<CancelOrderReq>,
289        req_ctx: RequestContext<RoleServer>,
290    ) -> std::result::Result<String, String> {
291        let header_token = http_bearer_token(&req_ctx);
292        let override_key = req.api_key.as_deref().or(header_token.as_deref());
293        let args_hash = guard::args_short_hash(&req);
294        tracing::warn!(
295            target: futu_auth::audit::TARGET,
296            iface = "mcp",
297            endpoint = "futu_cancel_order",
298            env = %req.env,
299            market = %req.market,
300            acc_id = req.acc_id.unwrap_or(0), // v1.4.105 T-D1: Option<u64> → u64 for audit log
301            card_num_provided = req.card_num.is_some(),
302            order_id = %req.order_id,
303            args_hash = %args_hash,
304            outcome = "request",
305            "cancel_order request received"
306        );
307        // codex round 1 F2 (P2) v1.4.105: caller key strict pre-check FIRST.
308        let caller_key_rec = match self.require_caller_key_strict("futu_cancel_order", override_key)
309        {
310            Ok(rec) => rec,
311            Err(reject_json) => return Err(reject_json),
312        };
313        // codex round 2 F1 (P2) v1.4.105: 早期 trade-scope 校验 (同 place_order).
314        if let Some(reject) =
315            self.require_trading_scope_only("futu_cancel_order", &req.env, caller_key_rec.as_ref())
316        {
317            return Err(reject);
318        }
319        // v1.4.105 D12: client_or_err + resolve card_num → acc_id 提前 (scope check 用)
320        let client = self.client_or_err().await?;
321        // v1.4.105 D12 contract-hardening 补丁: 同步传 allowed_card_nums 做 string-level
322        // whitelist 校验 (resolve 前). caller key 配置非空时, user 输 card_num
323        // 必须 ∈ 白名单 — UX clear.
324        // codex F2: 用 caller_key_rec (require_caller_key_strict 已验) 而非
325        // current_key_rec(override_key) 重新查.
326        let allowed_card_nums = caller_key_rec
327            .as_ref()
328            .and_then(|r| r.allowed_card_nums.as_deref());
329        // v1.4.106 codex round 2 F1 case 2 (P1) fix: caller-snapshot acc_id
330        // 早过滤 (同 place_order).
331        let caller_allowed_acc_ids = caller_key_rec
332            .as_ref()
333            .and_then(|r| r.allowed_acc_ids.as_ref());
334        let resolved_acc_id = match handlers::trade_write::resolve_acc_id_with_card_num(
335            &client,
336            req.acc_id.unwrap_or(0),
337            req.card_num.as_deref(),
338            allowed_card_nums,
339            caller_allowed_acc_ids,
340        )
341        .await
342        {
343            Ok(id) => id,
344            Err(msg) => return Self::tool_err(msg),
345        };
346        let mutation_ctx = CheckCtx {
347            market: req.market.trim().to_ascii_uppercase(),
348            symbol: String::new(),
349            order_value: None,
350            trd_side: None,
351            acc_id: Some(resolved_acc_id), // v1.4.35; v1.4.105 D12: resolved
352            mutation_no_exposure: false,
353            currency: None,
354        };
355        if let Some(rej) = self.require_trading(
356            "futu_cancel_order",
357            &req.env,
358            Some(mutation_ctx),
359            override_key,
360        ) {
361            // MED-2: scope 拒绝 → Err(rmcp set is_error=true)
362            return Err(rej);
363        }
364        let result = Self::wrap_result(
365            handlers::trade_write::cancel_order(
366                &client,
367                &req.env,
368                resolved_acc_id,
369                &req.market,
370                &req.order_id,
371                req.idempotency_key.clone(),
372            )
373            .await,
374        );
375        // codex round 1 F4 (P2) v1.4.106: snapshot reuse — 见 place_order 同段注释.
376        let outcome_key_id =
377            outcome_key_id_from_snapshot(caller_key_rec.as_ref(), self.state.authed_key.as_ref());
378        guard::emit_trade_outcome(
379            "futu_cancel_order",
380            outcome_key_id,
381            &args_hash,
382            Self::result_as_str(&result),
383        );
384        result
385    }
386
387    #[tool(
388        description = "⚠️ REAL MONEY when env=real. Reconfirm a pending high-risk or price-warning order by numeric order_id. REQUIRES --enable-trading; real env needs --allow-real-trading; gateway must have been unlocked via `futu_unlock_trade` first. Use only for orders that the backend explicitly asked to reconfirm."
389    )]
390    async fn futu_reconfirm_order(
391        &self,
392        Parameters(req): Parameters<ReconfirmOrderReq>,
393        req_ctx: RequestContext<RoleServer>,
394    ) -> std::result::Result<String, String> {
395        let header_token = http_bearer_token(&req_ctx);
396        let override_key = req.api_key.as_deref().or(header_token.as_deref());
397        let args_hash = guard::args_short_hash(&req);
398        tracing::warn!(
399            target: futu_auth::audit::TARGET,
400            iface = "mcp",
401            endpoint = "futu_reconfirm_order",
402            env = %req.env,
403            market = %req.market,
404            acc_id = req.acc_id.unwrap_or(0),
405            card_num_provided = req.card_num.is_some(),
406            order_id = %req.order_id,
407            reason = req.reason,
408            args_hash = %args_hash,
409            outcome = "request",
410            "reconfirm_order request received"
411        );
412        let caller_key_rec =
413            match self.require_caller_key_strict("futu_reconfirm_order", override_key) {
414                Ok(rec) => rec,
415                Err(reject_json) => return Err(reject_json),
416            };
417        if let Some(reject) = self.require_trading_scope_only(
418            "futu_reconfirm_order",
419            &req.env,
420            caller_key_rec.as_ref(),
421        ) {
422            return Err(reject);
423        }
424        let client = self.client_or_err().await?;
425        let allowed_card_nums = caller_key_rec
426            .as_ref()
427            .and_then(|r| r.allowed_card_nums.as_deref());
428        let caller_allowed_acc_ids = caller_key_rec
429            .as_ref()
430            .and_then(|r| r.allowed_acc_ids.as_ref());
431        let resolved_acc_id = match handlers::trade_write::resolve_acc_id_with_card_num(
432            &client,
433            req.acc_id.unwrap_or(0),
434            req.card_num.as_deref(),
435            allowed_card_nums,
436            caller_allowed_acc_ids,
437        )
438        .await
439        {
440            Ok(id) => id,
441            Err(msg) => return Self::tool_err(msg),
442        };
443        let mutation_ctx = CheckCtx {
444            market: req.market.trim().to_ascii_uppercase(),
445            symbol: String::new(),
446            order_value: None,
447            trd_side: None,
448            acc_id: Some(resolved_acc_id),
449            mutation_no_exposure: false,
450            currency: None,
451        };
452        if let Some(rej) = self.require_trading(
453            "futu_reconfirm_order",
454            &req.env,
455            Some(mutation_ctx),
456            override_key,
457        ) {
458            return Err(rej);
459        }
460        let result = Self::wrap_result(
461            handlers::trade_write::reconfirm_order(
462                &client,
463                handlers::trade_write::ReconfirmOrderInput {
464                    env: &req.env,
465                    acc_id: resolved_acc_id,
466                    market: &req.market,
467                    order_id: &req.order_id,
468                    reason: req.reason,
469                },
470            )
471            .await,
472        );
473        let outcome_key_id =
474            outcome_key_id_from_snapshot(caller_key_rec.as_ref(), self.state.authed_key.as_ref());
475        guard::emit_trade_outcome(
476            "futu_reconfirm_order",
477            outcome_key_id,
478            &args_hash,
479            Self::result_as_str(&result),
480        );
481        result
482    }
483
484    #[tool(
485        description = "Cancel all pending orders for an account in a specific market. `market` is REQUIRED (HK / US / HKCC / A_SH / A_SZ / SG / JP / AU / CA). Python SDK: OpenTradeContext.cancel_all_order. REQUIRES --enable-trading. Real env requires --allow-real-trading. DANGER: unrecoverable — cancels every pending order in the specified market immediately."
486    )]
487    async fn futu_cancel_all_order(
488        &self,
489        Parameters(req): Parameters<CancelAllOrderReq>,
490        req_ctx: RequestContext<RoleServer>,
491    ) -> std::result::Result<String, String> {
492        let header_token = http_bearer_token(&req_ctx);
493        let override_key = req.api_key.as_deref().or(header_token.as_deref());
494        let args_hash = guard::args_short_hash(&req);
495        tracing::warn!(
496            target: futu_auth::audit::TARGET,
497            iface = "mcp",
498            endpoint = "futu_cancel_all_order",
499            env = %req.env,
500            market = %req.market,
501            acc_id = req.acc_id,
502            args_hash = %args_hash,
503            outcome = "request",
504            "cancel_all_order request received"
505        );
506        // v1.4.34 MCP-3b 修:market 是必填,空字符串下发到后端会炸
507        // `unknown trd market ""` —— 这是个模糊的内部错误,LLM 客户端没法自修。
508        // 在 tool 层前置校验给清晰的必填提示 + 合法值列表。
509        // v1.4.84 §5 B4: 用集中的 validate() (其他 tool 也复用类似模式)
510        req.validate()?;
511        // codex round 1 F4 (P2) v1.4.106: lock caller key snapshot **早**
512        // (在 require_trading + daemon dispatch 之前), 用于 emit_trade_outcome
513        // 防 SIGHUP race. 与 place/modify/cancel_order 的 require_caller_key_strict
514        // 同语义 — invalid override 立即 fail-closed, scope mode 关闭则 Ok(None).
515        let caller_key_rec =
516            match self.require_caller_key_strict("futu_cancel_all_order", override_key) {
517                Ok(rec) => rec,
518                Err(reject_json) => return Err(reject_json),
519            };
520        let market_trimmed = req.market.trim();
521        let mutation_ctx = CheckCtx {
522            market: market_trimmed.to_ascii_uppercase(),
523            symbol: String::new(),
524            order_value: None,
525            trd_side: None,
526            acc_id: Some(req.acc_id), // v1.4.35
527            mutation_no_exposure: false,
528            currency: None,
529        };
530        if let Some(rej) = self.require_trading(
531            "futu_cancel_all_order",
532            &req.env,
533            Some(mutation_ctx),
534            override_key,
535        ) {
536            // MED-2: scope 拒绝 → Err(rmcp set is_error=true)
537            return Err(rej);
538        }
539        let client = self.client_or_err().await?;
540        let result = Self::wrap_result(
541            handlers::trade_write::cancel_all_order(&client, &req.env, req.acc_id, &req.market)
542                .await,
543        );
544        // codex round 1 F4 (P2) v1.4.106: snapshot reuse — 见 place_order 同段注释.
545        let outcome_key_id =
546            outcome_key_id_from_snapshot(caller_key_rec.as_ref(), self.state.authed_key.as_ref());
547        guard::emit_trade_outcome(
548            "futu_cancel_all_order",
549            outcome_key_id,
550            &args_hash,
551            Self::result_as_str(&result),
552        );
553        result
554    }
555
556    #[tool(
557        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)."
558    )]
559    async fn futu_unlock_trade(
560        &self,
561        Parameters(req): Parameters<UnlockTradeReq>,
562        req_ctx: RequestContext<RoleServer>,
563    ) -> std::result::Result<String, String> {
564        let args_hash = guard::args_short_hash(&req);
565        tracing::warn!(
566            target: futu_auth::audit::TARGET,
567            iface = "mcp",
568            endpoint = "futu_unlock_trade",
569            unlock = req.unlock,
570            args_hash = %args_hash,
571            outcome = "request",
572            "unlock_trade request received"
573        );
574        // v1.4.103 codex F5.3 (P1) round 5: 切到 caller-specific scope check.
575        // 之前 require_tool_scope 只看 startup key, 受限 Bearer 仍可用 startup
576        // key 的 trade:unlock scope 解锁所有账户.
577        //
578        // v1.4.104 阶段 7-5: 走 pipeline 做 caller-specific scope check + expiry
579        // + audit. acc_ids 多元素白名单 enforcement 仍在本地 inline (special
580        // semantic: 多 acc_id loop + "no acc_ids + restriction" reject; pipeline
581        // 单一 explicit_acc_id 不足以覆盖).
582        //
583        // 流程:
584        //   1. resolve caller credential (Bearer → verify, fail-closed; 无
585        //      Bearer → fall back startup key PreVerified).
586        //   2. pipeline: scope=TradeUnlock + audit_emit=true + commit_rate=false.
587        //      Reject → return JSON error.
588        //   3. inline per-acc_id whitelist enforcement (本节专属语义).
589        let bearer_token = http_bearer_token(&req_ctx);
590        let credential: futu_auth_pipeline::Credential<'_> = match bearer_token
591            .as_deref()
592            .filter(|s| !s.is_empty())
593        {
594            Some(t) => match self.state.key_store.verify(t) {
595                Some(rec) => futu_auth_pipeline::Credential::PreVerified(rec),
596                None => {
597                    // codex F4 fail-closed: invalid Bearer → reject, 不 fall back
598                    futu_auth::audit::reject(
599                        "mcp",
600                        "futu_unlock_trade",
601                        "<bearer-invalid>",
602                        "invalid Bearer (v1.4.103 codex F5.3 fail-closed)",
603                    );
604                    return Err(serde_json::json!({
605                            "error": "futu_unlock_trade: invalid Bearer token (v1.4.103 codex F5.3 fail-closed)",
606                            "status": "error",
607                        })
608                        .to_string());
609                }
610            },
611            // v1.4.106 codex 0608 F2 (P1): startup fallback 用
612            // `get_by_id_for_current_machine` 替代裸 `get_by_id`, 让 SIGHUP 收紧
613            // allowed_machines 后能立即 reject (与 Bearer 路径 verify 行为对称).
614            None => match self
615                .state
616                .authed_key
617                .as_ref()
618                .and_then(|k| self.state.key_store.get_by_id_for_current_machine(&k.id))
619            {
620                Some(rec) => futu_auth_pipeline::Credential::PreVerified(rec),
621                None => futu_auth_pipeline::Credential::None,
622            },
623        };
624
625        let unlock_env = futu_auth_pipeline::AuthEnvelope {
626            surface: futu_auth_pipeline::SurfaceId::Mcp,
627            endpoint: futu_auth_pipeline::Endpoint::McpTool("futu_unlock_trade"),
628            needed_scope: Some(futu_auth::Scope::TradeUnlock),
629            credential,
630            proto_id: None,
631            body: &[],
632            explicit_acc_id: None, // multi-acc 白名单单独 inline enforce
633            explicit_ctx: None,
634            commit_rate: false, // unlock 不计 rate
635            audit_emit: true,
636        };
637        let caller_key_for_unlock = match futu_auth_pipeline::authenticate_request(
638            &self.state.key_store,
639            &self.state.counters,
640            unlock_env,
641        ) {
642            futu_auth_pipeline::AuthDecision::Allow { rec, .. } => rec,
643            futu_auth_pipeline::AuthDecision::Reject {
644                kind,
645                reason,
646                audit_key_id,
647            } => {
648                use futu_auth_pipeline::RejectKind;
649                let err_msg = match kind {
650                    RejectKind::Unauthenticated => {
651                        format!("futu_unlock_trade: API key required or expired ({reason})")
652                    }
653                    RejectKind::Forbidden => format!(
654                        "futu_unlock_trade: API key {audit_key_id:?} forbidden — needs trade:unlock scope ({reason})"
655                    ),
656                    _ => reason.clone(),
657                };
658                return Err(serde_json::json!({
659                    "error": err_msg,
660                    "status": "error",
661                })
662                .to_string());
663            }
664        };
665        if let Err(err) = validate_unlock_trade_acc_ids(req.acc_ids.as_deref()) {
666            return Err(serde_json::json!({
667                "error": err,
668                "status": "error",
669            })
670            .to_string());
671        }
672        // 若 caller 的 key 有 allowed_acc_ids 限制 + caller 显式传 acc_ids:
673        //   每个 acc_id 必须 ∈ allowed_acc_ids
674        // 若 caller 显式 acc_ids 为 None / empty + key 有限制:
675        //   reject (ambiguous — 不让"unlock all" silent 解锁未授权账户)
676        if let Some(ref rec) = caller_key_for_unlock
677            && let Some(ref allowed) = rec.allowed_acc_ids
678            && !allowed.is_empty()
679        {
680            match req.acc_ids.as_ref() {
681                Some(ids) if !ids.is_empty() => {
682                    for id in ids {
683                        if !allowed.contains(id) {
684                            futu_auth::audit::reject(
685                                "mcp",
686                                "futu_unlock_trade",
687                                &rec.id,
688                                &format!("acc_id {id} not in allowed list"),
689                            );
690                            return Err(serde_json::json!({
691                                "error": format!(
692                                    "futu_unlock_trade: API key {:?} not allowed to unlock acc_id {id} (allowed_acc_ids restriction)",
693                                    rec.id
694                                ),
695                                "status": "error",
696                            })
697                            .to_string());
698                        }
699                    }
700                }
701                _ => {
702                    // 没传 acc_ids + key 有限制 → 不允许 silent unlock all
703                    return Err(serde_json::json!({
704                        "error": format!(
705                            "futu_unlock_trade: API key {:?} has allowed_acc_ids restriction \
706                             but acc_ids not specified. Restricted keys must explicitly pass \
707                             acc_ids; unlock-all is rejected to prevent unauthorized broker \
708                             unlock side effects.",
709                            rec.id
710                        ),
711                        "status": "error",
712                        "hint": "pass acc_ids: [<your-allowed-acc-id>]",
713                    })
714                    .to_string());
715                }
716            }
717        }
718
719        // lock 不需要密码,unlock 要从账号级 keychain/env 读。
720        // MCP 只连接 gateway,不能从本进程可靠推断 daemon login account;
721        // 因此账号 hint 来自 `--trade-pwd-account` / FUTU_TRADE_PWD_ACCOUNT。
722        let pwd_md5 = if req.unlock {
723            match crate::trade_pwd::get_trade_password_md5_for_account(
724                self.state.trade_pwd_account.as_deref(),
725            ) {
726                Ok(md5) => md5,
727                Err(e) => {
728                    let err = format!("unlock failed: {e}");
729                    tracing::warn!(
730                        target: futu_auth::audit::TARGET,
731                        iface = "mcp",
732                        endpoint = "futu_unlock_trade",
733                        outcome = "failure",
734                        reason = %err,
735                        "unlock failed: no password source"
736                    );
737                    return Self::tool_err(err);
738                }
739            }
740        } else {
741            String::new()
742        };
743
744        let client = self.client_or_err().await?;
745        let result = match futu_trd::account::unlock_trade(
746            &client,
747            &pwd_md5,
748            req.unlock,
749            req.otp.as_deref(),
750            req.security_firm,
751            req.acc_ids.clone().unwrap_or_default(),
752        )
753        .await
754        {
755            Ok(outcome) => {
756                if outcome.need_otp {
757                    // HIGH-2 修(code review):need_otp=true 是 unlock 失败的错误
758                    // 状态(用户需带 OTP 重试),应 set is_error=true 让 agent 通过
759                    // top-level envelope 感知,不需要 parse JSON `ok` 字段。
760                    // MED-NEW-1(2nd review):加 `error` field 让 emit_trade_outcome
761                    // 按 "failure" 记 audit log(之前 need_otp 会被错记 success)
762                    Err(serde_json::json!({
763                        "error": "unlock requires OTP (2FA token); pass otp= and retry",
764                        "need_otp": true,
765                        "message": outcome.message.unwrap_or_else(||
766                            "此账号开启了令牌动态密码(2FA)。\
767                             请重新调用 futu_unlock_trade 带 `otp` 参数(明文 OTP)".into()),
768                        "failed_accounts": outcome.failed_accounts,
769                        "status": "need_otp_retry",
770                    })
771                    .to_string())
772                } else if !req.unlock {
773                    Ok(
774                        serde_json::json!({ "ok": true, "message": "trade locked on gateway" })
775                            .to_string(),
776                    )
777                } else {
778                    let msg = if outcome.total_unlocked < outcome.total_requested {
779                        format!(
780                            "部分账户解锁成功({}/{})。\
781                             失败账户:{:?}(常见原因:品种权限未开通 / 影子子账户)",
782                            outcome.total_unlocked,
783                            outcome.total_requested,
784                            outcome.failed_accounts
785                        )
786                    } else {
787                        format!(
788                            "trade unlocked ({} accounts); cipher cached until gateway restarts",
789                            outcome.total_unlocked
790                        )
791                    };
792                    if outcome.total_unlocked > 0 {
793                        Ok(serde_json::json!({
794                            "ok": true,
795                            "need_otp": false,
796                            "total_requested": outcome.total_requested,
797                            "total_unlocked": outcome.total_unlocked,
798                            "failed_accounts": outcome.failed_accounts,
799                            "message": msg,
800                        })
801                        .to_string())
802                    } else {
803                        // MED-NEW-1(2nd review):总失败时加 `error` 让 audit log 按
804                        // failure 记录(不含则 emit_trade_outcome 错判 success)
805                        Err(serde_json::json!({
806                            "ok": false,
807                            "error": "all accounts failed to unlock",
808                            "need_otp": false,
809                            "total_requested": outcome.total_requested,
810                            "total_unlocked": outcome.total_unlocked,
811                            "failed_accounts": outcome.failed_accounts,
812                            "message": msg,
813                        })
814                        .to_string())
815                    }
816                }
817            }
818            Err(e) => Err(format!("unlock_trade RPC failed: {e}")),
819        };
820        // codex round 1 F5 (P2) v1.4.105 + v1.4.106 F4 alignment:
821        // emit_trade_outcome 用 caller_key_for_unlock 的 key id (pipeline Allow
822        // 时的 KeyRecord), **不**调 self.current_key_id(None) 重新 verify —
823        // 防 HTTP per-request Bearer 调 unlock 时 outcome 被错归 startup key
824        // (F5 root cause), 同时也防 SIGHUP race (F4 同模式 — dispatch 中途
825        // SIGHUP revoke caller key, snapshot 仍 hold 住).
826        //
827        // legacy mode (caller_key_for_unlock = None, scope mode 关闭) → 退化
828        // 到 startup authed_key (与 v1.4.103 兼容). authed_key 取 precheck 时
829        // 的 ref (snapshot), 同样不重新查 KeyStore.
830        let outcome_key_id = outcome_key_id_from_snapshot(
831            caller_key_for_unlock.as_ref(),
832            self.state.authed_key.as_ref(),
833        );
834        guard::emit_trade_outcome(
835            "futu_unlock_trade",
836            outcome_key_id,
837            &args_hash,
838            Self::result_as_str(&result),
839        );
840        result
841    }
842}