1use 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 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 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 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, explicit_ctx: None,
97 commit_rate: false, 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 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 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 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 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 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 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));