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()?;
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.authed_key().map(|k| k.id.clone()).unwrap_or_default();
141 audit(
142 tool,
143 Some(&id),
144 "reject",
145 "key revoked (not in current keys.json)",
146 );
147 return GuardOutcome::Reject(format!(
148 "API key {id:?} has been revoked (not in current keys.json)"
149 ));
150 };
151
152 if key.is_expired(Utc::now()) {
154 audit(tool, Some(&key.id), "reject", "key expired");
155 return GuardOutcome::Reject(format!(
156 "API key {:?} has expired (expires_at={:?})",
157 key.id, key.expires_at
158 ));
159 }
160
161 if !key.scopes.contains(&needed) {
162 audit(
163 tool,
164 Some(&key.id),
165 "reject",
166 &format!("missing scope {}", needed),
167 );
168 return GuardOutcome::Reject(format!(
169 "API key {:?} missing required scope {:?}",
170 key.id,
171 needed.as_str()
172 ));
173 }
174
175 audit(tool, Some(&key.id), "allow", "scope ok");
176 GuardOutcome::Allow
177}
178
179#[cfg(test)]
197pub fn require_trading(
198 state: &ServerState,
199 tool: &'static str,
200 env: &str,
201 ctx: Option<CheckCtx>,
202 override_key: Option<&str>,
203) -> GuardOutcome {
204 let is_real = crate::handlers::trade_write::is_real_env(env);
205 let needed_scope = if is_real {
206 Scope::TradeReal
207 } else {
208 Scope::TradeSimulate
209 };
210
211 if !state.is_scope_mode() {
212 if !state.enable_trading() {
214 audit(tool, None, "reject", "legacy: --enable-trading off");
215 return GuardOutcome::Reject(
216 "trading tools are disabled. Start futu-mcp with --enable-trading to enable."
217 .to_string(),
218 );
219 }
220 if is_real && !state.allow_real_trading() {
221 audit(
222 tool,
223 None,
224 "reject",
225 "legacy: real env but --allow-real-trading off",
226 );
227 return GuardOutcome::Reject(
228 "real trading is not allowed. Use env=\"simulate\" or restart futu-mcp with --allow-real-trading."
229 .to_string(),
230 );
231 }
232 audit(tool, None, "allow", "legacy trading allowed");
235 return GuardOutcome::Allow;
236 }
237
238 let key = if let Some(plaintext) = override_key.filter(|p| !p.is_empty()) {
240 match state.key_store().verify(plaintext) {
242 Some(rec) => rec,
243 None => {
244 audit(tool, None, "reject", "per-call api_key invalid");
245 return GuardOutcome::Reject(
246 "per-call api_key is invalid (not in keys.json or expired/bound to wrong machine)"
247 .to_string(),
248 );
249 }
250 }
251 } else {
252 if state.authed_key().is_none() {
253 audit(tool, None, "reject", "no API key");
254 return GuardOutcome::Reject(
255 "API key required for trading tools (set FUTU_MCP_API_KEY, or pass api_key in the tool call)"
256 .to_string(),
257 );
258 }
259 match current_authed_key(state) {
261 Some(k) => k,
262 None => {
263 let id = state.authed_key().map(|k| k.id.clone()).unwrap_or_default();
264 audit(tool, Some(&id), "reject", "key revoked");
265 return GuardOutcome::Reject(format!("API key {id:?} has been revoked"));
266 }
267 }
268 };
269
270 if key.is_expired(Utc::now()) {
271 audit(tool, Some(&key.id), "reject", "key expired");
272 return GuardOutcome::Reject(format!("API key {:?} has expired", key.id));
273 }
274
275 if !key.scopes.contains(&needed_scope) {
276 audit(
277 tool,
278 Some(&key.id),
279 "reject",
280 &format!("missing scope {}", needed_scope),
281 );
282 return GuardOutcome::Reject(format!(
283 "API key {:?} missing scope {:?}",
284 key.id,
285 needed_scope.as_str()
286 ));
287 }
288
289 if let Some(ctx) = ctx {
294 let outcome = state
295 .counters()
296 .check_and_commit(&key.id, &key.limits(), &ctx, Utc::now());
297 if outcome.is_allow() {
298 audit(tool, Some(&key.id), "allow", "scope + limits ok");
299 } else {
300 let reason = outcome
301 .reason()
302 .unwrap_or_else(|| "limit check failed".to_string());
303 audit(tool, Some(&key.id), "reject", &format!("limit: {reason}"));
304 return GuardOutcome::Reject(format!("limit check failed: {reason}"));
305 }
306 } else {
307 audit(tool, Some(&key.id), "allow", "scope ok (no limits ctx)");
308 }
309
310 GuardOutcome::Allow
311}
312
313#[cfg(test)]
315fn audit(tool: &str, key_id: Option<&str>, result: &str, reason: &str) {
316 let key_id = key_id.unwrap_or("<none>");
317 if result == "reject" {
318 futu_auth::audit::reject("mcp", tool, key_id, reason);
319 } else {
320 futu_auth::audit::allow("mcp", tool, key_id, Some(reason));
321 }
322}
323
324pub fn args_short_hash(args: &impl serde::Serialize) -> String {
326 let j = match serde_json::to_vec(args) {
327 Ok(v) => v,
328 Err(_) => return "n/a".into(),
329 };
330 let h = Sha256::digest(&j);
331 hex::encode(&h[..4])
332}
333
334pub fn emit_trade_outcome(tool: &'static str, key_id: Option<&str>, args_hash: &str, result: &str) {
337 let key_id = key_id.unwrap_or("<none>");
338 let (outcome, reason) = match serde_json::from_str::<serde_json::Value>(result) {
339 Ok(v) => match v.get("error").and_then(|e| e.as_str()) {
340 Some(err) => ("failure", Some(err.to_string())),
341 None => ("success", None),
342 },
343 Err(_) => ("unknown", Some("non-json response".to_string())),
344 };
345 futu_auth::audit::trade("mcp", tool, key_id, args_hash, outcome, reason.as_deref());
346}
347
348#[cfg(test)]
349mod tests;