futu_mcp/tool_auth.rs
1//! MCP caller-auth helper types and pure policy decisions.
2//!
3//! This module deliberately avoids concrete `#[tool]` handlers. It keeps the
4//! reusable identity snapshot / Bearer parsing / early trade-scope policy out of
5//! `tools.rs`, while `tools.rs` remains the thin dispatch surface.
6
7use crate::tools::FutuServer;
8use crate::{guard, handlers};
9use futu_auth::CheckCtx;
10use rmcp::{RoleServer, service::RequestContext};
11
12mod policy;
13pub(crate) use policy::{
14 CallerSnapshot, EarlyTradeScopeDecision, decide_early_trade_scope, http_bearer_token,
15 outcome_key_id_from_snapshot,
16};
17use policy::{audit_reject_with_context, mcp_audit_context, scope_label};
18
19impl FutuServer {
20 // v1.4.58 Phase C3 删除:`Self::err` + `Self::wrap` v1.4.42 遗留的 JSON
21 // `"isError": true` content-marker hack。所有 tool handler 已迁移到
22 // `Result<String, String>` 返回类型,rmcp 自动 set MCP spec 的
23 // `CallToolResult.is_error = Some(true)` on Err variant(top-level
24 // envelope 字段,对齐 MCP 协议)。用 `Self::tool_err` / `Self::wrap_result`
25 // 取代。
26 //
27 // v1.4.89 #7 "MCP isError 根治" 确认已落地 in-place —— 无需改 signature
28 // 到 `Result<CallToolResult, McpError>`。rmcp 1.4.0 提供 blanket
29 // `impl<T: IntoCallToolResult, E: IntoCallToolResult> IntoCallToolResult
30 // for Result<T, E>`(见 rmcp src/handler/server/tool.rs:100-112),
31 // `Err(String)` 分支自动 set `result.is_error = Some(true)` + content
32 // 保留 JSON body(老 client 兼容"error"/"status"字段双信号)。v1.4.89
33 // 补 3 条回归测试(`v1_4_89_rmcp_*`)锁死协议层契约。
34
35 /// v1.4.58 Phase C1: 新 helper — 返 `Result<String, String>` 让 rmcp 自动
36 /// set top-level `CallToolResult.is_error = Some(true)`(对齐 MCP spec)。
37 ///
38 /// 迁移策略(C1/C2/C3 拆 3 commit,都进 v1.4.58 一个版本):
39 /// - **C1**:加此 helper + 迁移 5-10 pilot handlers
40 /// - **C2**:批量迁移剩余 70+ handlers
41 /// - **C3**:删除 `Self::err` / `Self::wrap` String hack(v1.4.42 遗留)
42 ///
43 /// Client 双信号(向后兼容):
44 /// - Top-level `is_error: true`(MCP spec 正确方式)
45 /// - Content JSON 里仍含 `{"error": msg, ...}`(老 client 兼容)
46 ///
47 /// rmcp `IntoCallToolResult for Result<T, E>` impl 自动把 Err 转成
48 /// `CallToolResult { is_error: Some(true), content: [msg.into_contents()] }`。
49 pub(crate) fn tool_err(msg: impl std::fmt::Display) -> std::result::Result<String, String> {
50 Err(serde_json::json!({
51 "error": msg.to_string(),
52 "status": "error",
53 })
54 .to_string())
55 }
56
57 /// v1.4.106 D1 5d: MCP-specific reject translator (rich context: tool + audit_key_id).
58 ///
59 /// MCP 返 JSON-encoded `String` (rmcp `Err(String)` → 自动 `CallToolResult
60 /// { is_error: Some(true), ... }`, 见 `tool_err` 注释).
61 ///
62 /// **rich context 路径** (require_acc_read_with_acc_id / require_trading 等):
63 /// 把 `kind` + `reason` + `tool` + `audit_key_id` 翻成 user-friendly
64 /// MCP error JSON. 这里保留 tool name + audit_key_id, 让 LLM agent 知道
65 /// 是哪个 tool 哪把 key.
66 ///
67 /// **不变量** (与 v1.4.105 byte-identical):
68 /// - Unauthenticated → "API key required for {tool}: ..."
69 /// - Forbidden → "API key {audit_key_id:?} forbidden: {reason}"
70 /// (注意: reason 在 MCP 路径**保留**, 与 REST/gRPC generic 不同; MCP 是
71 /// LLM agent 调试场景, 反推风险低 + agent 需要清晰 hint 来纠正参数)
72 /// - RateLimited → "rate limit: {reason}"
73 /// - 其他 → reason
74 fn mcp_reject_to_json(
75 kind: futu_auth_pipeline::RejectKind,
76 reason: String,
77 tool: &str,
78 audit_key_id: &str,
79 ) -> String {
80 use futu_auth_pipeline::RejectKind;
81 let prefix = match kind {
82 RejectKind::Unauthenticated => format!(
83 "API key required for {tool}: provide via tool args api_key, \
84 HTTP Authorization Bearer, or set FUTU_MCP_API_KEY"
85 ),
86 RejectKind::Forbidden => {
87 // scope 不够 OR acc_id 不在白名单 — pipeline reason 已含细节.
88 // MCP 路径保留 reason (LLM agent 调试用), 不同 REST/gRPC 的 generic.
89 format!("API key {audit_key_id:?} forbidden: {reason}")
90 }
91 RejectKind::RateLimited => format!("rate limit: {reason}"),
92 _ => reason.clone(),
93 };
94 serde_json::json!({
95 "error": prefix,
96 "status": "error",
97 })
98 .to_string()
99 }
100
101 /// v1.4.58 Phase C1: `wrap` 新版 —— 返 `Result<String, String>`。C2 批量迁移时使用。
102 pub(crate) fn wrap_result<E: std::fmt::Display>(
103 res: std::result::Result<String, E>,
104 ) -> std::result::Result<String, String> {
105 match res {
106 Ok(s) => Ok(s),
107 Err(e) => Self::tool_err(e),
108 }
109 }
110
111 /// v1.4.58 Phase C2: 从 `Result<String, String>` 抽 string content 作 &str。
112 /// 用于 `guard::emit_trade_outcome` 等接受 &str 的 side-effect observers —
113 /// 无论 Ok/Err 都有 string 可引用。
114 pub(crate) fn result_as_str(r: &std::result::Result<String, String>) -> &str {
115 match r {
116 Ok(s) | Err(s) => s.as_str(),
117 }
118 }
119
120 pub(crate) async fn client_or_err(
121 &self,
122 ) -> std::result::Result<std::sync::Arc<futu_net::client::FutuClient>, String> {
123 self.state.client().await.map_err(|e| {
124 // MED-1 修(code review):error 返 JSON 格式和 tool_err 对齐,
125 // 让 agent 看到的 error shape 一致(tool_err / scope reject / connect
126 // 三类 error 都是 JSON with "error" + "status" fields)
127 serde_json::json!({
128 "error": format!("gateway connect failed: {e}"),
129 "status": "error",
130 })
131 .to_string()
132 })
133 }
134
135 /// Common MCP read path: caller-specific guard first, then gateway client.
136 ///
137 /// Used by read-only tools that do not need the returned caller snapshot for
138 /// account/card filtering. Account-specific tools still call
139 /// `require_acc_read_with_acc_id` directly so they can pass the same snapshot
140 /// into account locator / response filtering.
141 pub(crate) async fn read_client_or_err(
142 &self,
143 tool: &'static str,
144 req_ctx: &RequestContext<RoleServer>,
145 api_key_override: Option<&str>,
146 acc_id: Option<u64>,
147 ) -> std::result::Result<std::sync::Arc<futu_net::client::FutuClient>, String> {
148 self.require_acc_read_with_acc_id(tool, req_ctx, api_key_override, acc_id)?;
149 self.client_or_err().await
150 }
151
152 /// v1.4.103 (codex 51 F1 / 52 F1 / 53 F1 / 54 F4 / 58 F1 — B5 + B6):
153 /// per-request **caller-specific** scope 守卫 + acc_id 白名单 check.
154 ///
155 /// 旧 `require_tool_scope` 只看 process-wide `state.authed_key` (startup
156 /// 捕获), HTTP 客户端带窄权限 Bearer 时 read tool 仍按 startup key 放行 —
157 /// **跨账户 leak**.
158 ///
159 /// 本方法接 `req_ctx` (rmcp request context) + 可选 `api_key_override`
160 /// (tool args 里的 api_key 字段, 与 trade write tools 一致) + 可选 `acc_id`,
161 /// 优先级: api_key_override > HTTP Authorization Bearer > startup key.
162 ///
163 /// 解析得到 caller-specific KeyRecord 后:
164 /// 1. 检查 scope (基于 caller 的 scope, 不是 startup 的)
165 /// 2. 若 acc_id 提供 + caller key 有 allowed_acc_ids → 检查 acc_id ∈ allowed
166 ///
167 /// stdio mode (无 Bearer) + 无 api_key_override → fall back 到 startup key
168 /// 行为, 不破坏 stdio 用户体验.
169 ///
170 /// 返 Some(error_json) 拒绝, None 放行.
171 ///
172 /// ## v1.4.104 阶段 4: pipeline 委托
173 ///
174 /// caller-specific KeyRecord 解析 + Bearer 不存在的 fail-closed 仍在本地
175 /// (要保留 v1.4.103 codex F4 verbose error message). scope check + acc_id
176 /// 白名单 + audit emit 委托给 [`futu_auth_pipeline::authenticate_request`]
177 /// (跨 surface 共享: gRPC server.rs / WS ws_listener.rs / REST auth.rs 同源).
178 /// LoC 减 ~80 行, 行为与 v1.4.103 byte-identical.
179 pub(crate) fn require_acc_read_with_acc_id(
180 &self,
181 tool: &'static str,
182 req_ctx: &RequestContext<RoleServer>,
183 api_key_override: Option<&str>,
184 acc_id: Option<u64>,
185 ) -> Result<CallerSnapshot, String> {
186 // v1.4.106 D1 5d: RejectKind 已移到 mcp_reject_to_json (rich-context),
187 // 此 fn 内不再直接 match RejectKind.
188 use futu_auth_pipeline::{
189 AuthDecision, AuthEnvelope, Credential, Endpoint, SurfaceId, authenticate_request,
190 };
191
192 let audit_ctx = mcp_audit_context(req_ctx);
193 let header_token = http_bearer_token(req_ctx);
194 let plaintext_override = api_key_override
195 .filter(|s| !s.is_empty())
196 .or(header_token.as_deref())
197 .filter(|s| !s.is_empty());
198
199 // v1.4.103 codex F4 (P1) fail-closed: caller-supplied Bearer/api_key
200 // verify 失败 → **立即 reject** 不 fall back 到 startup key (跨租户 leak).
201 // 这一段保留在本地 (不进 pipeline) 是为了保留 v1.4.103 verbose error JSON
202 // (LLM agent 看到 "v1.4.103 codex F4 fail-closed" 等明确指引).
203 //
204 // v1.4.104 codex round 1 F3 (P2) fix: 同时 capture caller's KeyRecord
205 // snapshot (Option<Arc<KeyRecord>>), 后续放进 CallerSnapshot 让 call
206 // sites 用同一身份做 response filter / push subscriber ownership /
207 // visibility — 不再 re-resolve from Bearer/startup (TOCTOU + drift risk).
208 let resolved_rec: Option<std::sync::Arc<futu_auth::KeyRecord>> = match plaintext_override {
209 Some(p) => match self.state.key_store().verify(p) {
210 Some(rec) => Some(rec),
211 None => {
212 audit_reject_with_context(
213 &audit_ctx,
214 tool,
215 "<bearer-invalid>",
216 "invalid HTTP Bearer / api_key — fail-closed (no fallback to startup key)",
217 );
218 return Err(serde_json::json!({
219 "error": format!(
220 "{tool}: invalid Bearer token / api_key argument. \
221 v1.4.103 codex F4 fail-closed — daemon does NOT fall back \
222 to startup key when caller-supplied auth fails verification."
223 ),
224 "status": "error",
225 })
226 .to_string());
227 }
228 },
229 // v1.4.106 codex 0608 F2 (P1): startup fallback 用
230 // `get_by_id_for_current_machine` 替代裸 `get_by_id`, 让 SIGHUP
231 // 收紧 allowed_machines 后能立即 reject (与 Bearer 路径 verify
232 // 自带 machine 校验行为对称).
233 None => self
234 .state
235 .authed_key()
236 .and_then(|k| self.state.key_store().get_by_id_for_current_machine(&k.id)),
237 };
238 let credential: Credential<'_> = match &resolved_rec {
239 Some(rec) => Credential::PreVerified(rec.clone()),
240 None => Credential::None,
241 };
242
243 // 防御性: scope_for_tool 返 None / Trade → 走 trade guard 报错路径,
244 // 不进 pipeline (pipeline 不知 MCP tool taxonomy).
245 let needed_scope = match guard::scope_for_tool(tool) {
246 Some(guard::ToolScope::Read(s)) => Some(s),
247 Some(guard::ToolScope::Trade) => {
248 audit_reject_with_context(
249 &audit_ctx,
250 tool,
251 "<misrouted>",
252 "internal: trade tool misrouted to read guard",
253 );
254 return Err(serde_json::json!({
255 "error": format!(
256 "internal error: {tool} is a trade tool, must use require_trading"
257 ),
258 "status": "error",
259 })
260 .to_string());
261 }
262 None => {
263 audit_reject_with_context(&audit_ctx, tool, "<unknown>", "unknown MCP tool");
264 return Err(serde_json::json!({
265 "error": format!("unknown MCP tool {tool:?}"),
266 "status": "error",
267 })
268 .to_string());
269 }
270 };
271
272 // Pipeline: scope check + expiry + acc_id 白名单 + audit emit 一处.
273 // - explicit_acc_id: MCP tool args 直接给 (跳 body decode, MCP 无 raw proto body)
274 // - commit_rate=false: read tool 不 commit rate (rate gate 是 trade write 专属)
275 let env = AuthEnvelope {
276 surface: SurfaceId::Mcp,
277 endpoint: Endpoint::McpTool(tool),
278 needed_scope,
279 credential,
280 proto_id: None,
281 body: &[],
282 explicit_acc_id: acc_id,
283 explicit_ctx: None,
284 commit_rate: false,
285 audit_emit: true,
286 };
287
288 match futu_auth::audit::with_context(audit_ctx.clone(), || {
289 authenticate_request(self.state.key_store(), self.state.counters(), env)
290 }) {
291 AuthDecision::Allow {
292 allowed_acc_ids, ..
293 } => {
294 // v1.4.104 codex F3 (P2): 返 caller snapshot 让 call sites
295 // 用同一身份做 response filter / push ownership.
296 Ok(CallerSnapshot {
297 key_id: resolved_rec.as_ref().map(|r| r.id.clone()),
298 rec: resolved_rec,
299 allowed_acc_ids,
300 bearer_token: header_token,
301 })
302 }
303 AuthDecision::Reject {
304 kind,
305 reason,
306 audit_key_id,
307 } => {
308 // pipeline 已 audit reject; v1.4.106 D1 5d: 走 rich-context
309 // helper, 翻成 MCP-specific JSON.
310 Err(Self::mcp_reject_to_json(kind, reason, tool, &audit_key_id))
311 }
312 }
313 }
314
315 /// 交易写守卫;ctx=Some 时同时做限额检查;override_key=Some 时优先用该 plaintext.
316 ///
317 /// ## v1.4.104 阶段 7-4: pipeline 委托
318 ///
319 /// 把 `guard::require_trading` 165 LoC 折叠为 ~70 LoC 调用 pipeline:
320 /// - **legacy 2 级开关 (`enable_trading` / `allow_real_trading`) 仍在本地**
321 /// (MCP-specific, pipeline 不知 daemon 启动 flag).
322 /// - per-call `override_key` 仍在本地 verify (保留 v1.4.103 codex F4
323 /// verbose error message: "per-call api_key invalid").
324 /// - **scope check + expiry + rate gate + body-aware ctx + audit** 全
325 /// 委托 `authenticate_request` (与 4 surface unified).
326 pub(crate) fn require_trading(
327 &self,
328 tool: &'static str,
329 env: &str,
330 ctx: Option<CheckCtx>,
331 override_key: Option<&str>,
332 ) -> Option<String> {
333 use futu_auth_pipeline::{
334 AuthDecision, AuthEnvelope, Credential, Endpoint, RejectKind, SurfaceId,
335 authenticate_request,
336 };
337
338 let is_real = handlers::trade_write::is_real_env(env);
339 let needed_scope = if is_real {
340 futu_auth::Scope::TradeReal
341 } else {
342 futu_auth::Scope::TradeSimulate
343 };
344
345 // ── Legacy 2 级开关 (MCP-specific, 不进 pipeline) ────────────────────────
346 if !self.state.is_scope_mode() {
347 if !self.state.enable_trading() {
348 futu_auth::audit::reject("mcp", tool, "<legacy>", "legacy: --enable-trading off");
349 return Some(
350 serde_json::json!({
351 "error": "trading tools are disabled. Start futu-mcp with --enable-trading to enable.",
352 "status": "error",
353 })
354 .to_string(),
355 );
356 }
357 if is_real && !self.state.allow_real_trading() {
358 futu_auth::audit::reject(
359 "mcp",
360 tool,
361 "<legacy>",
362 "legacy: real env but --allow-real-trading off",
363 );
364 return Some(
365 serde_json::json!({
366 "error": "real trading is not allowed. Use env=\"simulate\" or restart futu-mcp with --allow-real-trading.",
367 "status": "error",
368 })
369 .to_string(),
370 );
371 }
372 futu_auth::audit::allow("mcp", tool, "<legacy>", Some("legacy trading allowed"));
373 return None;
374 }
375
376 // ── Resolve credential (per-call override or startup) ─────────────────────
377 // per-call override 失败 → MCP-specific verbose reject (与 v1.4.103 兼容).
378 let credential: Credential<'_> = if let Some(plaintext) =
379 override_key.filter(|p| !p.is_empty())
380 {
381 match self.state.key_store().verify(plaintext) {
382 Some(rec) => Credential::PreVerified(rec),
383 None => {
384 futu_auth::audit::reject(
385 "mcp",
386 tool,
387 "<override-invalid>",
388 "per-call api_key invalid",
389 );
390 return Some(
391 serde_json::json!({
392 "error": "per-call api_key is invalid (not in keys.json or expired/bound to wrong machine)",
393 "status": "error",
394 })
395 .to_string(),
396 );
397 }
398 }
399 } else {
400 let startup = self.state.authed_key();
401 if let Some(startup) = startup.as_ref() {
402 // SIGHUP-aware fresh lookup + machine binding 校验
403 // v1.4.106 codex 0608 F2 (P1): get_by_id_for_current_machine 替代裸
404 // get_by_id, machine binding 失败也按 "key revoked" 处理.
405 match self
406 .state
407 .key_store()
408 .get_by_id_for_current_machine(&startup.id)
409 {
410 Some(rec) => Credential::PreVerified(rec),
411 None => {
412 futu_auth::audit::reject("mcp", tool, &startup.id, "key revoked");
413 return Some(
414 serde_json::json!({
415 "error": format!("API key {:?} has been revoked", startup.id),
416 "status": "error",
417 })
418 .to_string(),
419 );
420 }
421 }
422 } else {
423 futu_auth::audit::reject("mcp", tool, "<none>", "no API key");
424 return Some(
425 serde_json::json!({
426 "error": "API key required for trading tools (set FUTU_MCP_API_KEY, or pass api_key in the tool call)",
427 "status": "error",
428 })
429 .to_string(),
430 );
431 }
432 };
433
434 // ── Pipeline: scope check + expiry + rate gate (commit) + ctx-aware ──────
435 // commit_rate=true: trade write 是 MCP rate gate (与 v1.4.103
436 // `state.counters.check_and_commit(ctx, ...)` 行为对齐, 模拟 + 真单都
437 // commit rate, 防 simulate flood backend).
438 // explicit_ctx: 全 ctx 走 body-aware loop (market/symbol/value/side/acc_id 全检查).
439 let env_envelope = AuthEnvelope {
440 surface: SurfaceId::Mcp,
441 endpoint: Endpoint::McpTool(tool),
442 needed_scope: Some(needed_scope),
443 credential,
444 proto_id: None,
445 body: &[],
446 explicit_acc_id: None,
447 explicit_ctx: ctx.clone(),
448 commit_rate: true,
449 audit_emit: true,
450 };
451
452 match authenticate_request(self.state.key_store(), self.state.counters(), env_envelope) {
453 AuthDecision::Allow { .. } => None,
454 AuthDecision::Reject {
455 kind,
456 reason,
457 audit_key_id,
458 } => {
459 // v1.4.106 D1 5d: 走 rich-context helper.
460 // **行为差异 (intentional)**: trade 路径 Unauthenticated 文案
461 // 与 require_acc_read_with_acc_id 不同 — read 路径强调"提供 key",
462 // trade 路径强调"key expired/revoked" (caller 已知有 key 但 verify
463 // 后 expired/revoked, e.g. SIGHUP reload 后 key 失效).
464 // 这里 inline match 保留, 不进 mcp_reject_to_json.
465 let prefix = match kind {
466 RejectKind::Unauthenticated => {
467 format!("API key {audit_key_id:?} expired or revoked: {reason}")
468 }
469 RejectKind::Forbidden => {
470 format!("API key {audit_key_id:?} forbidden: {reason}")
471 }
472 RejectKind::RateLimited => format!("rate limit: {reason}"),
473 _ => reason.clone(),
474 };
475 Some(
476 serde_json::json!({
477 "error": prefix,
478 "status": "error",
479 })
480 .to_string(),
481 )
482 }
483 }
484 }
485
486 // v1.4.106 codex round 1 F4 (P2): `current_key_id(&self, Option<&str>)`
487 // 已废弃删除. 之前 5 处 emit_trade_outcome 用它做 daemon dispatch 后的
488 // audit attribution, 但 SIGHUP reload 在 dispatch 中途 revoke caller 的
489 // key 时, 该 helper 会 silent fallback 到 startup key → audit 记录被错
490 // 归属. 现统一用 [`outcome_key_id_from_snapshot`] 取 precheck 时的
491 // snapshot — race-free.
492 //
493 // 历史调用点 (全部已迁移):
494 // - futu_place_order / futu_modify_order / futu_cancel_order /
495 // futu_reconfirm_order / futu_cancel_all_order / futu_unlock_trade
496 // (6 处 emit_trade_outcome)
497 //
498 // 没有其他 surface caller (grep 全 workspace 已确认).
499
500 /// v1.4.105 D12 contract-hardening 补丁: 拿当前 caller 的 KeyRecord (per-call key
501 /// 优先 > startup key). legacy mode (无 keys.json) 返 None.
502 /// 用于 trade tool 调 resolve_acc_id_with_card_num 时获取
503 /// `allowed_card_nums` 做 string-level whitelist 校验.
504 /// codex round 1 F2 (P2) v1.4.105 移除老的 `current_key_rec` —
505 /// 改用下面 `require_caller_key_strict` (fail-closed). 移除原因: invalid
506 /// override 时 silent fallback startup → 给 backend resolve_acc_id_with_card_num
507 /// 探测 leak. legacy mode 仍由 strict helper Ok(None) 返回处理.
508 ///
509 /// codex round 1 F2 (P2) v1.4.105: 在 trade write 路径里**先**验证 caller
510 /// key + fail-closed, **再** resolve_acc_id_with_card_num. 防 invalid
511 /// Bearer 仍触发 daemon GetAccList + 用 startup key 的 `allowed_card_nums`
512 /// 做 resolution (探测 leakage).
513 ///
514 /// 与 `current_key_rec` 区别:
515 /// - `current_key_rec`: invalid override → silent fallback startup key →
516 /// resolve 已 side-effect.
517 /// - **`require_caller_key_strict`**: invalid override → 立即 Err 返 reject
518 /// JSON, **绝不 fallback**. 也保护 legacy mode (返 Ok(None)) 不破.
519 ///
520 /// Return:
521 /// - `Ok(Some(rec))`: caller key 已验, 用其 `allowed_card_nums` 做 resolve
522 /// - `Ok(None)`: scope mode 关闭 (legacy), require_trading 后续会处理
523 /// - `Err(json_str)`: invalid override / no key / startup key revoked,
524 /// 立即 abort
525 pub(crate) fn require_caller_key_strict(
526 &self,
527 tool: &'static str,
528 override_key: Option<&str>,
529 ) -> std::result::Result<Option<std::sync::Arc<futu_auth::KeyRecord>>, String> {
530 // legacy 模式 (无 keys.json) 不强制 — 后续 require_trading 仍会过 legacy
531 // toggle, 此处放行 (返 Ok(None)) 保持向后兼容
532 if !self.state.is_scope_mode() {
533 return Ok(None);
534 }
535
536 if let Some(pt) = override_key.filter(|p| !p.is_empty()) {
537 // 显式传 override_key → 验证, 无 fallback 防 leak
538 match self.state.key_store().verify(pt) {
539 Some(rec) => Ok(Some(rec)),
540 None => {
541 futu_auth::audit::reject(
542 "mcp",
543 tool,
544 "<override-invalid>",
545 "per-call api_key invalid (pre-resolve fail-closed)",
546 );
547 Err(serde_json::json!({
548 "error": "per-call api_key is invalid (not in keys.json or expired/bound to wrong machine)",
549 "status": "error",
550 })
551 .to_string())
552 }
553 }
554 } else {
555 let startup = self.state.authed_key();
556 if let Some(startup) = startup.as_ref() {
557 // 无 override → 用 startup key (SIGHUP-aware fresh lookup + machine 校验)
558 // v1.4.106 codex 0608 F2 (P1): get_by_id_for_current_machine 替代裸
559 // get_by_id, machine 失败 / id 失踪都按 "key revoked" 处理.
560 match self
561 .state
562 .key_store()
563 .get_by_id_for_current_machine(&startup.id)
564 {
565 Some(rec) => Ok(Some(rec)),
566 None => {
567 futu_auth::audit::reject(
568 "mcp",
569 tool,
570 &startup.id,
571 "key revoked (pre-resolve fail-closed)",
572 );
573 Err(serde_json::json!({
574 "error": format!("API key {:?} has been revoked", startup.id),
575 "status": "error",
576 })
577 .to_string())
578 }
579 }
580 } else {
581 // scope 模式但无 startup key 也无 override → 立即 reject
582 futu_auth::audit::reject(
583 "mcp",
584 tool,
585 "<none>",
586 "no API key (pre-resolve fail-closed)",
587 );
588 Err(serde_json::json!({
589 "error": "API key required for trading tools (set FUTU_MCP_API_KEY, or pass api_key in the tool call)",
590 "status": "error",
591 })
592 .to_string())
593 }
594 }
595 }
596
597 /// codex round 2 F1 (P2) v1.4.105: trade write 路径 **早期 trade-scope
598 /// 校验** — 在 `client_or_err` + `resolve_acc_id_with_card_num` 之前.
599 ///
600 /// 与 `require_trading` (full ctx body-aware) 区别:
601 /// - `require_trading`: 完整 scope + acc_id whitelist + market/symbol/value
602 /// + rate gate (最终 gate, 在 resolve 之后).
603 /// - **`require_trading_scope_only`**: 只 verify caller key 含 `trade:real`
604 /// 或 `trade:simulate` scope. **不**检查 acc_id / market / symbol /
605 /// value (这些 final gate 仍由 `require_trading` 后做).
606 ///
607 /// **目标**: 防 valid 但**非-trade key** (e.g. `qot:read` only) 触发 daemon
608 /// `GetAccList` + `card_num` resolution → 探测 not-found / ambiguous /
609 /// existence timing & messages, 之后才被 final `require_trading` 拒绝.
610 /// 早期 scope check 让此类 key 在 resolve 之前 fail-closed.
611 ///
612 /// **不替代** `require_trading` — 只前置一个轻量 scope guard. 后续 final
613 /// gate (含 ctx) 仍跑.
614 pub(crate) fn require_trading_scope_only(
615 &self,
616 tool: &'static str,
617 env: &str,
618 caller_key_rec: Option<&std::sync::Arc<futu_auth::KeyRecord>>,
619 ) -> Option<String> {
620 match decide_early_trade_scope(env, self.state.is_scope_mode(), caller_key_rec) {
621 EarlyTradeScopeDecision::Allow => None,
622 EarlyTradeScopeDecision::RejectMissingCallerKey => {
623 futu_auth::audit::reject(
624 "mcp",
625 tool,
626 "<no-caller-key>",
627 "early-trade-scope: caller key snapshot missing (defensive)",
628 );
629 Some(
630 serde_json::json!({
631 "error": "internal: caller key missing for early trade-scope check",
632 "status": "error",
633 })
634 .to_string(),
635 )
636 }
637 EarlyTradeScopeDecision::RejectMissingScope { needed, key_id } => {
638 futu_auth::audit::reject(
639 "mcp",
640 tool,
641 &key_id,
642 &format!("early-trade-scope: missing {needed:?} — pre-resolve fail-closed"),
643 );
644 Some(
645 serde_json::json!({
646 "error": format!(
647 "API key {:?} forbidden — needs {} scope",
648 key_id, scope_label(needed)
649 ),
650 "status": "error",
651 })
652 .to_string(),
653 )
654 }
655 }
656 }
657}
658
659#[cfg(test)]
660mod tests;