1#[cfg(test)]
9use std::sync::Arc;
10
11#[cfg(test)]
12use chrono::Utc;
13use futu_auth::Scope;
14#[cfg(test)]
15use futu_auth::{CheckCtx, KeyRecord};
16use sha2::{Digest, Sha256};
17
18#[cfg(test)]
19use crate::state::ServerState;
20
21#[cfg(test)]
30fn current_authed_key(state: &ServerState) -> Option<Arc<KeyRecord>> {
31 let startup = state.authed_key.as_ref()?;
32 state.key_store.get_by_id_for_current_machine(&startup.id)
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum ToolScope {
44 Read(Scope),
46 Trade,
48}
49
50pub fn scope_for_tool(tool: &str) -> Option<ToolScope> {
57 let spec = futu_surface_spec::lookup_endpoint_by_mcp_tool(tool)?;
58 Some(match spec.runtime.scope {
59 futu_auth::Scope::TradeReal | futu_auth::Scope::TradeSimulate => ToolScope::Trade,
60 scope => ToolScope::Read(scope),
61 })
62}
63
64#[cfg(test)]
71pub fn require_tool_scope(state: &ServerState, tool: &'static str) -> GuardOutcome {
72 match scope_for_tool(tool) {
73 Some(ToolScope::Read(s)) => require_scope(state, tool, s),
74 Some(ToolScope::Trade) => {
75 audit(tool, None, "reject", "internal: trade tool misrouted");
77 GuardOutcome::Reject(format!(
78 "internal error: {tool} is a trade tool, must use require_trading"
79 ))
80 }
81 None => {
82 audit(tool, None, "reject", "unknown MCP tool");
83 GuardOutcome::Reject(format!("unknown MCP tool {tool:?}"))
84 }
85 }
86}
87
88#[cfg(test)]
89#[non_exhaustive]
91pub enum GuardOutcome {
92 Allow,
94 Reject(String),
96}
97
98#[cfg(test)]
99impl GuardOutcome {
100 pub fn into_err_json(self) -> Option<String> {
106 match self {
107 GuardOutcome::Allow => None,
108 GuardOutcome::Reject(msg) => {
111 Some(serde_json::json!({ "error": msg, "status": "error" }).to_string())
112 }
113 }
114 }
115}
116
117#[cfg(test)]
123pub fn require_scope(state: &ServerState, tool: &'static str, needed: Scope) -> GuardOutcome {
124 if !state.is_scope_mode() {
125 audit(tool, None, "allow", "legacy mode, no keys configured");
127 return GuardOutcome::Allow;
128 }
129
130 if state.authed_key.is_none() {
131 audit(tool, None, "reject", "no API key provided");
132 return GuardOutcome::Reject(
133 "API key required: set FUTU_MCP_API_KEY to a plaintext key listed in keys.json"
134 .to_string(),
135 );
136 }
137
138 let Some(key) = current_authed_key(state) else {
140 let id = state
141 .authed_key
142 .as_ref()
143 .map(|k| k.id.clone())
144 .unwrap_or_default();
145 audit(
146 tool,
147 Some(&id),
148 "reject",
149 "key revoked (not in current keys.json)",
150 );
151 return GuardOutcome::Reject(format!(
152 "API key {id:?} has been revoked (not in current keys.json)"
153 ));
154 };
155
156 if key.is_expired(Utc::now()) {
158 audit(tool, Some(&key.id), "reject", "key expired");
159 return GuardOutcome::Reject(format!(
160 "API key {:?} has expired (expires_at={:?})",
161 key.id, key.expires_at
162 ));
163 }
164
165 if !key.scopes.contains(&needed) {
166 audit(
167 tool,
168 Some(&key.id),
169 "reject",
170 &format!("missing scope {}", needed),
171 );
172 return GuardOutcome::Reject(format!(
173 "API key {:?} missing required scope {:?}",
174 key.id,
175 needed.as_str()
176 ));
177 }
178
179 audit(tool, Some(&key.id), "allow", "scope ok");
180 GuardOutcome::Allow
181}
182
183#[cfg(test)]
201pub fn require_trading(
202 state: &ServerState,
203 tool: &'static str,
204 env: &str,
205 ctx: Option<CheckCtx>,
206 override_key: Option<&str>,
207) -> GuardOutcome {
208 let is_real = crate::handlers::trade_write::is_real_env(env);
209 let needed_scope = if is_real {
210 Scope::TradeReal
211 } else {
212 Scope::TradeSimulate
213 };
214
215 if !state.is_scope_mode() {
216 if !state.enable_trading {
218 audit(tool, None, "reject", "legacy: --enable-trading off");
219 return GuardOutcome::Reject(
220 "trading tools are disabled. Start futu-mcp with --enable-trading to enable."
221 .to_string(),
222 );
223 }
224 if is_real && !state.allow_real_trading {
225 audit(
226 tool,
227 None,
228 "reject",
229 "legacy: real env but --allow-real-trading off",
230 );
231 return GuardOutcome::Reject(
232 "real trading is not allowed. Use env=\"simulate\" or restart futu-mcp with --allow-real-trading."
233 .to_string(),
234 );
235 }
236 audit(tool, None, "allow", "legacy trading allowed");
239 return GuardOutcome::Allow;
240 }
241
242 let key = if let Some(plaintext) = override_key.filter(|p| !p.is_empty()) {
244 match state.key_store.verify(plaintext) {
246 Some(rec) => rec,
247 None => {
248 audit(tool, None, "reject", "per-call api_key invalid");
249 return GuardOutcome::Reject(
250 "per-call api_key is invalid (not in keys.json or expired/bound to wrong machine)"
251 .to_string(),
252 );
253 }
254 }
255 } else {
256 if state.authed_key.is_none() {
257 audit(tool, None, "reject", "no API key");
258 return GuardOutcome::Reject(
259 "API key required for trading tools (set FUTU_MCP_API_KEY, or pass api_key in the tool call)"
260 .to_string(),
261 );
262 }
263 match current_authed_key(state) {
265 Some(k) => k,
266 None => {
267 let id = state
268 .authed_key
269 .as_ref()
270 .map(|k| k.id.clone())
271 .unwrap_or_default();
272 audit(tool, Some(&id), "reject", "key revoked");
273 return GuardOutcome::Reject(format!("API key {id:?} has been revoked"));
274 }
275 }
276 };
277
278 if key.is_expired(Utc::now()) {
279 audit(tool, Some(&key.id), "reject", "key expired");
280 return GuardOutcome::Reject(format!("API key {:?} has expired", key.id));
281 }
282
283 if !key.scopes.contains(&needed_scope) {
284 audit(
285 tool,
286 Some(&key.id),
287 "reject",
288 &format!("missing scope {}", needed_scope),
289 );
290 return GuardOutcome::Reject(format!(
291 "API key {:?} missing scope {:?}",
292 key.id,
293 needed_scope.as_str()
294 ));
295 }
296
297 if let Some(ctx) = ctx {
302 let outcome = state
303 .counters
304 .check_and_commit(&key.id, &key.limits(), &ctx, Utc::now());
305 if outcome.is_allow() {
306 audit(tool, Some(&key.id), "allow", "scope + limits ok");
307 } else {
308 let reason = outcome
309 .reason()
310 .unwrap_or_else(|| "limit check failed".to_string());
311 audit(tool, Some(&key.id), "reject", &format!("limit: {reason}"));
312 return GuardOutcome::Reject(format!("limit check failed: {reason}"));
313 }
314 } else {
315 audit(tool, Some(&key.id), "allow", "scope ok (no limits ctx)");
316 }
317
318 GuardOutcome::Allow
319}
320
321#[cfg(test)]
323fn audit(tool: &str, key_id: Option<&str>, result: &str, reason: &str) {
324 let key_id = key_id.unwrap_or("<none>");
325 if result == "reject" {
326 futu_auth::audit::reject("mcp", tool, key_id, reason);
327 } else {
328 futu_auth::audit::allow("mcp", tool, key_id, Some(reason));
329 }
330}
331
332pub fn args_short_hash(args: &impl serde::Serialize) -> String {
334 let j = match serde_json::to_vec(args) {
335 Ok(v) => v,
336 Err(_) => return "n/a".into(),
337 };
338 let h = Sha256::digest(&j);
339 hex::encode(&h[..4])
340}
341
342pub fn emit_trade_outcome(tool: &'static str, key_id: Option<&str>, args_hash: &str, result: &str) {
345 let key_id = key_id.unwrap_or("<none>");
346 let (outcome, reason) = match serde_json::from_str::<serde_json::Value>(result) {
347 Ok(v) => match v.get("error").and_then(|e| e.as_str()) {
348 Some(err) => ("failure", Some(err.to_string())),
349 None => ("success", None),
350 },
351 Err(_) => ("unknown", Some("non-json response".to_string())),
352 };
353 futu_auth::audit::trade("mcp", tool, key_id, args_hash, outcome, reason.as_deref());
354}
355
356#[cfg(test)]
357mod tests;