1use 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 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 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 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, explicit_ctx: None,
103 commit_rate: false, 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 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 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 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 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 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 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}