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> {
56 Some(match crate::tools::generated_mcp_scope(tool)? {
57 futu_auth::Scope::TradeReal | futu_auth::Scope::TradeSimulate => ToolScope::Trade,
58 scope => ToolScope::Read(scope),
59 })
60}
61
62#[cfg(test)]
69pub fn require_tool_scope(state: &ServerState, tool: &'static str) -> GuardOutcome {
70 match scope_for_tool(tool) {
71 Some(ToolScope::Read(s)) => require_scope(state, tool, s),
72 Some(ToolScope::Trade) => {
73 audit(tool, None, "reject", "internal: trade tool misrouted");
75 GuardOutcome::Reject(format!(
76 "internal error: {tool} is a trade tool, must use require_trading"
77 ))
78 }
79 None => {
80 audit(tool, None, "reject", "unknown MCP tool");
81 GuardOutcome::Reject(format!("unknown MCP tool {tool:?}"))
82 }
83 }
84}
85
86#[cfg(test)]
87#[non_exhaustive]
89pub enum GuardOutcome {
90 Allow,
92 Reject(String),
94}
95
96#[cfg(test)]
97impl GuardOutcome {
98 pub fn into_err_json(self) -> Option<String> {
104 match self {
105 GuardOutcome::Allow => None,
106 GuardOutcome::Reject(msg) => {
109 Some(serde_json::json!({ "error": msg, "status": "error" }).to_string())
110 }
111 }
112 }
113}
114
115#[cfg(test)]
121pub fn require_scope(state: &ServerState, tool: &'static str, needed: Scope) -> GuardOutcome {
122 if !state.is_scope_mode() {
123 audit(tool, None, "allow", "legacy mode, no keys configured");
125 return GuardOutcome::Allow;
126 }
127
128 if state.authed_key().is_none() {
129 audit(tool, None, "reject", "no API key provided");
130 return GuardOutcome::Reject(
131 "API key required: set FUTU_MCP_API_KEY to a plaintext key listed in keys.json"
132 .to_string(),
133 );
134 }
135
136 let Some(key) = current_authed_key(state) else {
138 let id = state.authed_key().map(|k| k.id.clone()).unwrap_or_default();
139 audit(
140 tool,
141 Some(&id),
142 "reject",
143 "key revoked (not in current keys.json)",
144 );
145 return GuardOutcome::Reject(format!(
146 "API key {id:?} has been revoked (not in current keys.json)"
147 ));
148 };
149
150 if key.is_expired(Utc::now()) {
152 audit(tool, Some(&key.id), "reject", "key expired");
153 return GuardOutcome::Reject(format!(
154 "API key {:?} has expired (expires_at={:?})",
155 key.id, key.expires_at
156 ));
157 }
158
159 if !key.scopes.contains(&needed) {
160 audit(
161 tool,
162 Some(&key.id),
163 "reject",
164 &format!("missing scope {}", needed),
165 );
166 return GuardOutcome::Reject(format!(
167 "API key {:?} missing required scope {:?}",
168 key.id,
169 needed.as_str()
170 ));
171 }
172
173 audit(tool, Some(&key.id), "allow", "scope ok");
174 GuardOutcome::Allow
175}
176
177#[cfg(test)]
195pub fn require_trading(
196 state: &ServerState,
197 tool: &'static str,
198 env: &str,
199 ctx: Option<CheckCtx>,
200 override_key: Option<&str>,
201) -> GuardOutcome {
202 let is_real = crate::handlers::trade_write::is_real_env(env);
203 let needed_scope = futu_auth::trade_scope_for_env_is_real(is_real);
204
205 if !state.is_scope_mode() {
206 if !state.enable_trading() {
208 audit(tool, None, "reject", "legacy: --enable-trading off");
209 return GuardOutcome::Reject(
210 "trading tools are disabled. Start futu-mcp with --enable-trading to enable."
211 .to_string(),
212 );
213 }
214 if is_real && !state.allow_real_trading() {
215 audit(
216 tool,
217 None,
218 "reject",
219 "legacy: real env but --allow-real-trading off",
220 );
221 return GuardOutcome::Reject(
222 "real trading is not allowed. Use env=\"simulate\" or restart futu-mcp with --allow-real-trading."
223 .to_string(),
224 );
225 }
226 audit(tool, None, "allow", "legacy trading allowed");
229 return GuardOutcome::Allow;
230 }
231
232 let key = if let Some(plaintext) = override_key.filter(|p| !p.is_empty()) {
234 match state.key_store().verify(plaintext) {
236 Some(rec) => rec,
237 None => {
238 audit(tool, None, "reject", "per-call api_key invalid");
239 return GuardOutcome::Reject(
240 "per-call api_key is invalid (not in keys.json or expired/bound to wrong machine)"
241 .to_string(),
242 );
243 }
244 }
245 } else {
246 if state.authed_key().is_none() {
247 audit(tool, None, "reject", "no API key");
248 return GuardOutcome::Reject(
249 "API key required for trading tools (set FUTU_MCP_API_KEY, or pass api_key in the tool call)"
250 .to_string(),
251 );
252 }
253 match current_authed_key(state) {
255 Some(k) => k,
256 None => {
257 let id = state.authed_key().map(|k| k.id.clone()).unwrap_or_default();
258 audit(tool, Some(&id), "reject", "key revoked");
259 return GuardOutcome::Reject(format!("API key {id:?} has been revoked"));
260 }
261 }
262 };
263
264 if key.is_expired(Utc::now()) {
265 audit(tool, Some(&key.id), "reject", "key expired");
266 return GuardOutcome::Reject(format!("API key {:?} has expired", key.id));
267 }
268
269 if !key.scopes.contains(&needed_scope) {
270 audit(
271 tool,
272 Some(&key.id),
273 "reject",
274 &format!("missing scope {}", needed_scope),
275 );
276 return GuardOutcome::Reject(format!(
277 "API key {:?} missing scope {:?}",
278 key.id,
279 needed_scope.as_str()
280 ));
281 }
282
283 if let Some(ctx) = ctx {
288 let outcome = state
289 .counters()
290 .check_and_commit(&key.id, key.as_ref(), &ctx, Utc::now());
291 if outcome.is_allow() {
292 audit(tool, Some(&key.id), "allow", "scope + limits ok");
293 } else {
294 let reason = outcome
295 .reason()
296 .unwrap_or_else(|| "limit check failed".to_string());
297 audit(tool, Some(&key.id), "reject", &format!("limit: {reason}"));
298 return GuardOutcome::Reject(format!("limit check failed: {reason}"));
299 }
300 } else {
301 audit(tool, Some(&key.id), "allow", "scope ok (no limits ctx)");
302 }
303
304 GuardOutcome::Allow
305}
306
307#[cfg(test)]
309fn audit(tool: &str, key_id: Option<&str>, result: &str, reason: &str) {
310 let key_id = key_id.unwrap_or("<none>");
311 if result == "reject" {
312 futu_auth::audit::reject("mcp", tool, key_id, reason);
313 } else {
314 futu_auth::audit::allow("mcp", tool, key_id, Some(reason));
315 }
316}
317
318pub fn args_short_hash(args: &impl serde::Serialize) -> String {
320 let j = match serde_json::to_vec(args) {
321 Ok(v) => v,
322 Err(_) => return "n/a".into(),
323 };
324 let h = Sha256::digest(&j);
325 hex::encode(&h[..4])
326}
327
328pub fn emit_trade_outcome(tool: &'static str, key_id: Option<&str>, args_hash: &str, result: &str) {
331 let key_id = key_id.unwrap_or("<none>");
332 let (outcome, reason) = match serde_json::from_str::<serde_json::Value>(result) {
333 Ok(v) => match v.get("error").and_then(|e| e.as_str()) {
334 Some(err) => ("failure", Some(err.to_string())),
335 None => ("success", None),
336 },
337 Err(_) => ("unknown", Some("non-json response".to_string())),
338 };
339 futu_auth::audit::trade("mcp", tool, key_id, args_hash, outcome, reason.as_deref());
340}
341
342#[cfg(test)]
343mod tests;