1use std::sync::Arc;
9
10use chrono::Utc;
11use futu_auth::{CheckCtx, KeyRecord, LimitOutcome, Scope};
12use sha2::{Digest, Sha256};
13
14use crate::state::ServerState;
15
16fn current_authed_key(state: &ServerState) -> Option<Arc<KeyRecord>> {
21 let startup = state.authed_key.as_ref()?;
22 state.key_store.get_by_id(&startup.id)
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ToolScope {
33 Read(Scope),
35 Trade,
37}
38
39pub fn scope_for_tool(tool: &str) -> Option<ToolScope> {
47 match tool {
48 "futu_ping" | "futu_get_quote" | "futu_get_snapshot" | "futu_get_kline"
50 | "futu_get_orderbook" | "futu_get_ticker" | "futu_get_rt" | "futu_get_static"
51 | "futu_get_broker" | "futu_list_plates" | "futu_plate_stocks" => {
52 Some(ToolScope::Read(Scope::QotRead))
53 }
54
55 "futu_list_accounts" | "futu_get_funds" | "futu_get_positions" | "futu_get_orders"
57 | "futu_get_deals" => Some(ToolScope::Read(Scope::AccRead)),
58
59 "futu_place_order" | "futu_modify_order" | "futu_cancel_order" => Some(ToolScope::Trade),
61
62 _ => None,
64 }
65}
66
67pub fn require_tool_scope(state: &ServerState, tool: &'static str) -> GuardOutcome {
71 match scope_for_tool(tool) {
72 Some(ToolScope::Read(s)) => require_scope(state, tool, s),
73 Some(ToolScope::Trade) => {
74 audit(tool, None, "reject", "internal: trade tool misrouted");
76 GuardOutcome::Reject(format!(
77 "internal error: {tool} is a trade tool, must use require_trading"
78 ))
79 }
80 None => {
81 audit(tool, None, "reject", "unknown MCP tool");
82 GuardOutcome::Reject(format!("unknown MCP tool {tool:?}"))
83 }
84 }
85}
86
87pub enum GuardOutcome {
89 Allow,
90 Reject(String),
91}
92
93impl GuardOutcome {
94 pub fn into_err_json(self) -> Option<String> {
95 match self {
96 GuardOutcome::Allow => None,
97 GuardOutcome::Reject(msg) => Some(serde_json::json!({ "error": msg }).to_string()),
98 }
99 }
100}
101
102pub fn require_scope(state: &ServerState, tool: &'static str, needed: Scope) -> GuardOutcome {
106 if !state.is_scope_mode() {
107 audit(tool, None, "allow", "legacy mode, no keys configured");
109 return GuardOutcome::Allow;
110 }
111
112 if state.authed_key.is_none() {
113 audit(tool, None, "reject", "no API key provided");
114 return GuardOutcome::Reject(
115 "API key required: set FUTU_MCP_API_KEY to a plaintext key listed in keys.json"
116 .to_string(),
117 );
118 }
119
120 let Some(key) = current_authed_key(state) else {
122 let id = state
123 .authed_key
124 .as_ref()
125 .map(|k| k.id.clone())
126 .unwrap_or_default();
127 audit(
128 tool,
129 Some(&id),
130 "reject",
131 "key revoked (not in current keys.json)",
132 );
133 return GuardOutcome::Reject(format!(
134 "API key {id:?} has been revoked (not in current keys.json)"
135 ));
136 };
137
138 if key.is_expired(Utc::now()) {
140 audit(tool, Some(&key.id), "reject", "key expired");
141 return GuardOutcome::Reject(format!(
142 "API key {:?} has expired (expires_at={:?})",
143 key.id, key.expires_at
144 ));
145 }
146
147 if !key.scopes.contains(&needed) {
148 audit(
149 tool,
150 Some(&key.id),
151 "reject",
152 &format!("missing scope {}", needed),
153 );
154 return GuardOutcome::Reject(format!(
155 "API key {:?} missing required scope {:?}",
156 key.id,
157 needed.as_str()
158 ));
159 }
160
161 audit(tool, Some(&key.id), "allow", "scope ok");
162 GuardOutcome::Allow
163}
164
165pub fn require_trading(
174 state: &ServerState,
175 tool: &'static str,
176 env: &str,
177 ctx: Option<CheckCtx>,
178 override_key: Option<&str>,
179) -> GuardOutcome {
180 let is_real = crate::handlers::trade_write::is_real_env(env);
181 let needed_scope = if is_real {
182 Scope::TradeReal
183 } else {
184 Scope::TradeSimulate
185 };
186
187 if !state.is_scope_mode() {
188 if !state.enable_trading {
190 audit(tool, None, "reject", "legacy: --enable-trading off");
191 return GuardOutcome::Reject(
192 "trading tools are disabled. Start futu-mcp with --enable-trading to enable."
193 .to_string(),
194 );
195 }
196 if is_real && !state.allow_real_trading {
197 audit(
198 tool,
199 None,
200 "reject",
201 "legacy: real env but --allow-real-trading off",
202 );
203 return GuardOutcome::Reject(
204 "real trading is not allowed. Use env=\"simulate\" or restart futu-mcp with --allow-real-trading."
205 .to_string(),
206 );
207 }
208 audit(tool, None, "allow", "legacy trading allowed");
211 return GuardOutcome::Allow;
212 }
213
214 let key = if let Some(plaintext) = override_key.filter(|p| !p.is_empty()) {
216 match state.key_store.verify(plaintext) {
218 Some(rec) => rec,
219 None => {
220 audit(tool, None, "reject", "per-call api_key invalid");
221 return GuardOutcome::Reject(
222 "per-call api_key is invalid (not in keys.json or expired/bound to wrong machine)"
223 .to_string(),
224 );
225 }
226 }
227 } else {
228 if state.authed_key.is_none() {
229 audit(tool, None, "reject", "no API key");
230 return GuardOutcome::Reject(
231 "API key required for trading tools (set FUTU_MCP_API_KEY, or pass api_key in the tool call)"
232 .to_string(),
233 );
234 }
235 match current_authed_key(state) {
237 Some(k) => k,
238 None => {
239 let id = state
240 .authed_key
241 .as_ref()
242 .map(|k| k.id.clone())
243 .unwrap_or_default();
244 audit(tool, Some(&id), "reject", "key revoked");
245 return GuardOutcome::Reject(format!("API key {id:?} has been revoked"));
246 }
247 }
248 };
249
250 if key.is_expired(Utc::now()) {
251 audit(tool, Some(&key.id), "reject", "key expired");
252 return GuardOutcome::Reject(format!("API key {:?} has expired", key.id));
253 }
254
255 if !key.scopes.contains(&needed_scope) {
256 audit(
257 tool,
258 Some(&key.id),
259 "reject",
260 &format!("missing scope {}", needed_scope),
261 );
262 return GuardOutcome::Reject(format!(
263 "API key {:?} missing scope {:?}",
264 key.id,
265 needed_scope.as_str()
266 ));
267 }
268
269 if let Some(ctx) = ctx {
271 match state
272 .counters
273 .check_and_commit(&key.id, &key.limits(), &ctx, Utc::now())
274 {
275 LimitOutcome::Allow => {
276 audit(tool, Some(&key.id), "allow", "scope + limits ok");
277 }
278 LimitOutcome::Reject(reason) => {
279 audit(tool, Some(&key.id), "reject", &format!("limit: {reason}"));
280 return GuardOutcome::Reject(format!("limit check failed: {reason}"));
281 }
282 }
283 } else {
284 audit(tool, Some(&key.id), "allow", "scope ok (no limits ctx)");
285 }
286
287 GuardOutcome::Allow
288}
289
290fn audit(tool: &str, key_id: Option<&str>, result: &str, reason: &str) {
292 let key_id = key_id.unwrap_or("<none>");
293 if result == "reject" {
294 futu_auth::audit::reject("mcp", tool, key_id, reason);
295 } else {
296 futu_auth::audit::allow("mcp", tool, key_id, Some(reason));
297 }
298}
299
300pub fn args_short_hash(args: &impl serde::Serialize) -> String {
302 let j = match serde_json::to_vec(args) {
303 Ok(v) => v,
304 Err(_) => return "n/a".into(),
305 };
306 let h = Sha256::digest(&j);
307 hex::encode(&h[..4])
308}
309
310pub fn emit_trade_outcome(tool: &'static str, key_id: Option<&str>, args_hash: &str, result: &str) {
313 let key_id = key_id.unwrap_or("<none>");
314 let (outcome, reason) = match serde_json::from_str::<serde_json::Value>(result) {
315 Ok(v) => match v.get("error").and_then(|e| e.as_str()) {
316 Some(err) => ("failure", Some(err.to_string())),
317 None => ("success", None),
318 },
319 Err(_) => ("unknown", Some("non-json response".to_string())),
320 };
321 futu_auth::audit::trade("mcp", tool, key_id, args_hash, outcome, reason.as_deref());
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
331 fn all_known_tools_have_scopes() {
332 let known: &[&str] = &[
333 "futu_ping",
335 "futu_get_quote",
336 "futu_get_snapshot",
337 "futu_get_kline",
338 "futu_get_orderbook",
339 "futu_get_ticker",
340 "futu_get_rt",
341 "futu_get_static",
342 "futu_get_broker",
343 "futu_list_plates",
344 "futu_plate_stocks",
345 "futu_list_accounts",
347 "futu_get_funds",
348 "futu_get_positions",
349 "futu_get_orders",
350 "futu_get_deals",
351 "futu_place_order",
353 "futu_modify_order",
354 "futu_cancel_order",
355 ];
356 for t in known {
357 assert!(
358 scope_for_tool(t).is_some(),
359 "tool {t:?} is declared but not mapped in scope_for_tool()"
360 );
361 }
362 }
363
364 #[test]
365 fn unknown_tool_fails_closed() {
366 assert!(scope_for_tool("futu_transfer_money").is_none());
367 assert!(scope_for_tool("").is_none());
368 }
369
370 use futu_auth::{KeyRecord, KeyStore};
373 use std::path::PathBuf;
374 use tempfile::TempDir;
375
376 fn mk_two_key_store() -> (Arc<KeyStore>, String, String, String, String, TempDir) {
379 let dir = tempfile::tempdir().unwrap();
380 let path: PathBuf = dir.path().join("keys.json");
381 let (pt_sim, r_sim) = KeyRecord::generate(
382 "bot-sim",
383 [Scope::TradeSimulate].into_iter().collect(),
384 None,
385 None,
386 None,
387 );
388 let (pt_real, r_real) = KeyRecord::generate(
389 "bot-real",
390 [Scope::TradeReal].into_iter().collect(),
391 None,
392 None,
393 None,
394 );
395 let sid = r_sim.id.clone();
396 let rid = r_real.id.clone();
397 futu_auth::store::append_key(&path, r_sim).unwrap();
398 futu_auth::store::append_key(&path, r_real).unwrap();
399 let store = Arc::new(KeyStore::load(&path).unwrap());
400 (store, pt_sim, pt_real, sid, rid, dir)
401 }
402
403 fn mk_state(store: Arc<KeyStore>, startup_plaintext: &str) -> ServerState {
404 let rec = store.verify(startup_plaintext);
405 ServerState::new("dummy:0".to_string())
406 .with_key_store(store)
407 .with_authed_key(rec)
408 }
409
410 #[test]
411 fn override_key_none_uses_startup_key() {
412 let (store, pt_sim, _pt_real, _, _, _dir) = mk_two_key_store();
413 let state = mk_state(store, &pt_sim);
414 let r = require_trading(&state, "futu_place_order", "simulate", None, None);
416 assert!(matches!(r, GuardOutcome::Allow));
417 let r = require_trading(&state, "futu_place_order", "real", None, None);
419 assert!(matches!(r, GuardOutcome::Reject(_)));
420 }
421
422 #[test]
423 fn override_key_some_valid_uses_that_key_scope() {
424 let (store, pt_sim, pt_real, _, _, _dir) = mk_two_key_store();
425 let state = mk_state(store, &pt_sim);
428 let r = require_trading(
429 &state,
430 "futu_place_order",
431 "real",
432 None,
433 Some(pt_real.as_str()),
434 );
435 assert!(matches!(r, GuardOutcome::Allow), "override 应让 real 通过");
436 }
437
438 #[test]
439 fn override_key_invalid_is_rejected_not_fallback() {
440 let (store, pt_sim, _pt_real, _, _, _dir) = mk_two_key_store();
441 let state = mk_state(store, &pt_sim);
442 let r = require_trading(
444 &state,
445 "futu_place_order",
446 "simulate",
447 None,
448 Some("deadbeef"),
449 );
450 assert!(matches!(r, GuardOutcome::Reject(_)));
452 if let GuardOutcome::Reject(msg) = r {
453 assert!(
454 msg.contains("per-call api_key is invalid"),
455 "错误文案: {msg}"
456 );
457 }
458 }
459
460 #[test]
461 fn override_key_empty_string_treated_as_none() {
462 let (store, pt_sim, _pt_real, _, _, _dir) = mk_two_key_store();
464 let state = mk_state(store, &pt_sim);
465 let r = require_trading(&state, "futu_place_order", "simulate", None, Some(""));
466 assert!(matches!(r, GuardOutcome::Allow));
467 }
468
469 #[test]
470 fn override_key_scope_mismatch_is_rejected() {
471 let (store, pt_sim, _pt_real, _, _, _dir) = mk_two_key_store();
473 let state = mk_state(store, &pt_sim);
474 let r = require_trading(
475 &state,
476 "futu_place_order",
477 "real",
478 None,
479 Some(pt_sim.as_str()),
480 );
481 assert!(matches!(r, GuardOutcome::Reject(_)));
482 }
483}