Skip to main content

futu_mcp/
main.rs

1//! FutuOpenD-rs MCP 服务器
2//!
3//! 通过 Model Context Protocol 把 Futu 行情/账户能力暴露给 Claude / LLM 客户端。
4//!
5//! 授权有两种模式:
6//!
7//! - **Scope 模式**:`--keys-file <path>` 启用,客户端必须通过 `FUTU_MCP_API_KEY`
8//!   环境变量传入明文 key。服务器用 SHA-256 hash 比对 keys.json 中的记录,
9//!   按 scope + 限额放行。
10//! - **Legacy 模式**:未提供 keys-file 时回退到旧的
11//!   `--enable-trading` / `--allow-real-trading` 两级开关。
12
13mod guard;
14mod handlers;
15mod state;
16mod tool_account;
17mod tool_args;
18mod tool_auth;
19mod tool_enums;
20mod tools;
21mod trade_pwd;
22// v1.4.90 P0-A: resilient stdio transport — recovers from malformed JSON
23// (e.g. `{"price": Infinity}`) instead of `exit(0)`-ing the whole server.
24// See crates/futu-mcp/src/transport.rs for full rationale.
25mod transport;
26
27use std::path::PathBuf;
28use std::sync::Arc;
29
30use anyhow::{Context, Result};
31use clap::{ArgMatches, CommandFactory, FromArgMatches, Parser, parser::ValueSource};
32use futu_auth::KeyStore;
33use rmcp::ServiceExt;
34// v1.4.90 P0-A: stdio() (rmcp default) treats parse errors as fatal — see transport.rs.
35use crate::transport::resilient_stdio;
36use tracing_subscriber::{
37    EnvFilter, Layer, filter::filter_fn, fmt, layer::SubscriberExt, util::SubscriberInitExt,
38};
39
40use crate::state::ServerState;
41use crate::tools::FutuServer;
42
43/// 初始化 stderr 日志 + 可选 audit JSONL 层
44///
45/// - 常规事件走 stderr(no-ansi,因为 MCP client 的 stderr 往往不是 tty)
46/// - 如果 `audit_path` 传了,加一个 target=futu_audit 的 JSON 层写到文件/目录
47fn setup_logging(
48    default_level: &str,
49    audit_path: Option<&std::path::Path>,
50) -> Result<Option<tracing_appender::non_blocking::WorkerGuard>> {
51    let filter =
52        EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level));
53
54    let fmt_layer = fmt::layer()
55        .with_timer(futu_core::log::LocalRfc3339Timer)
56        .with_writer(std::io::stderr)
57        .with_ansi(false);
58
59    let registry = tracing_subscriber::registry().with(filter).with(fmt_layer);
60
61    if let Some(path) = audit_path {
62        let (writer, guard) = futu_auth::audit::open_writer(path)
63            .with_context(|| format!("open audit log {}", path.display()))?;
64        let audit_layer = fmt::layer()
65            .json()
66            .with_timer(futu_core::log::LocalRfc3339Timer)
67            .flatten_event(true)
68            .with_current_span(false)
69            .with_span_list(false)
70            .with_target(true)
71            .with_writer(writer)
72            .with_filter(filter_fn(|meta| meta.target() == futu_auth::audit::TARGET));
73        registry.with(audit_layer).init();
74        tracing::info!(
75            path = %path.display(),
76            "audit JSONL logger enabled (target=futu_audit)"
77        );
78        Ok(Some(guard))
79    } else {
80        registry.init();
81        Ok(None)
82    }
83}
84
85/// FutuOpenD-rs MCP server
86#[derive(Parser)]
87#[command(
88    name = "futu-mcp",
89    version,
90    about = "FutuOpenD-rs MCP server",
91    long_about = "通过 Model Context Protocol 暴露 Futu 行情/账户工具。默认 stdio transport。"
92)]
93struct Cli {
94    /// 网关地址(可用 FUTU_GATEWAY 环境变量覆盖)
95    #[arg(short, long, env = "FUTU_GATEWAY", default_value = "127.0.0.1:11111")]
96    gateway: String,
97
98    /// 启用 debug 日志
99    #[arg(short, long)]
100    verbose: bool,
101
102    /// Scope 模式:加载 keys.json 文件(API Key 授权)。
103    ///
104    /// 启用后所有工具调用必须带 FUTU_MCP_API_KEY 环境变量,
105    /// scope / 限额由 keys.json 配置决定;此时 --enable-trading / --allow-real-trading 被忽略。
106    #[arg(long)]
107    keys_file: Option<PathBuf>,
108
109    /// 调用方 API Key 明文(等价于 FUTU_MCP_API_KEY 环境变量)
110    ///
111    /// 生产环境强烈建议用环境变量而非命令行参数(后者会进 `ps` 输出)。
112    #[arg(long, env = "FUTU_MCP_API_KEY", hide_env_values = true)]
113    api_key: Option<String>,
114
115    /// 交易密码所属登录账号,用于读取账号级 keychain 条目。
116    ///
117    /// 对应 `futucli set-trade-pwd --account <login-account>` 写入的
118    /// `trade-password.<login-account>`。未设置时会尝试 FUTU_ACCOUNT,再兜底
119    /// 旧全局 keychain 条目和 FUTU_TRADE_PWD。
120    #[arg(long, env = "FUTU_TRADE_PWD_ACCOUNT")]
121    trade_pwd_account: Option<String>,
122
123    /// [Legacy] 启用交易写工具(place / modify / cancel)。默认关闭。
124    ///
125    /// 开启后默认仅允许 simulate 环境;要操作真实账户需额外 --allow-real-trading。
126    /// 注意:下单前网关必须已 unlock_trade(密码不经过 MCP / LLM)。
127    /// 若提供了 --keys-file,此开关被忽略,改由 key 的 scope 决定。
128    #[arg(long)]
129    enable_trading: bool,
130
131    /// [Legacy] 允许交易写工具对 real 环境执行。必须与 --enable-trading 搭配。
132    #[arg(long, requires = "enable_trading")]
133    allow_real_trading: bool,
134
135    /// 审计日志输出:JSONL 文件路径或目录
136    ///
137    /// - 带扩展名(`/var/log/futu-mcp-audit.jsonl`)→ 单文件 append
138    /// - 不带扩展名 / 以 `/` 结尾 → 每日滚动 `futu-audit.log` + 日期
139    ///
140    /// 只记录 auth / 交易 事件(target = `futu_audit`)。
141    #[arg(long)]
142    audit_log: Option<PathBuf>,
143
144    /// 以 HTTP transport 启动(streamable HTTP),监听该端口(格式 `host:port` 或 `:port`)
145    ///
146    /// 默认 stdio:LLM 客户端启子进程走 stdin/stdout。开 HTTP 后可以让多个
147    /// 客户端连同一个 MCP 进程,并同时暴露 `/metrics`。per-call key 覆盖依然
148    /// 走 tool args 的 `api_key` 字段;HTTP-layer 的 Authorization header 未来
149    /// 版本再接(v1.0 先做传输层切换)。
150    ///
151    /// 例:`--http-listen 127.0.0.1:3000` / `--http-listen :3000`
152    #[arg(long)]
153    http_listen: Option<String>,
154
155    /// TLS 证书文件路径(PEM 格式;需与 --tls-key 配合)
156    ///
157    /// 启用后 HTTP transport 走 HTTPS。若不设置,走纯 HTTP(建议前置 Caddy / Nginx
158    /// 做 TLS 终止)。
159    #[arg(long, requires = "tls_key")]
160    tls_cert: Option<PathBuf>,
161
162    /// TLS 私钥文件路径(PEM 格式;需与 --tls-cert 配合)
163    #[arg(long, requires = "tls_cert")]
164    tls_key: Option<PathBuf>,
165
166    /// TOML 配置文件路径(字段名与 CLI 参数一致,CLI 参数覆盖配置文件)
167    ///
168    /// 示例:
169    /// ```toml
170    /// gateway = "10.0.0.1:11111"
171    /// http_listen = ":3000"
172    /// keys_file = "/etc/futu/keys.json"
173    /// audit_log = "/var/log/futu-mcp-audit.jsonl"
174    /// tls_cert = "/etc/futu/cert.pem"
175    /// tls_key  = "/etc/futu/key.pem"
176    /// ```
177    #[arg(long)]
178    config: Option<PathBuf>,
179}
180
181/// TOML 配置文件映射——字段名与 CLI 参数完全一致
182///
183/// codex 0547 F4 (P2) fix: 加 `#[serde(deny_unknown_fields)]` — 与 `futu-opend`
184/// XmlConfig (BUG-006 v1.4.102 加的) 同语义级别. typo (e.g. `keys_flie` /
185/// `auditlog`) 之前 silent drop, 用户配置 silent 失效:
186/// - `key_file` typo → keystore 不加载 → MCP 进 legacy mode (无 scope)
187/// - `http_litsen` typo → HTTP transport 不启动 (默认 stdio)
188/// - `auditlog` typo → 审计文件无事件
189///
190/// 修后: 任何 unknown field / typo / `[server]` 类未支持 section 立即 parse
191/// fatal, daemon abort + 清晰错误.
192///
193/// **不 break 老 deprecated alias**: 没有 alias 历史, MCP TOML schema 自 v1.0
194/// 起字段名稳定, 升级用户无 typo 不会被影响.
195#[derive(Debug, Default, serde::Deserialize)]
196#[serde(default, deny_unknown_fields)]
197struct FileConfig {
198    gateway: Option<String>,
199    verbose: Option<bool>,
200    keys_file: Option<PathBuf>,
201    api_key: Option<String>,
202    trade_pwd_account: Option<String>,
203    enable_trading: Option<bool>,
204    allow_real_trading: Option<bool>,
205    audit_log: Option<PathBuf>,
206    http_listen: Option<String>,
207    tls_cert: Option<PathBuf>,
208    tls_key: Option<PathBuf>,
209}
210
211/// codex 0547 F5 (P3) fix: clap `ValueSource` 区分 "CLI 显式传" vs "默认值".
212///
213/// 之前用 `self.gateway == "127.0.0.1:11111"` 判 "CLI 没传" — 当用户**显式**
214/// 传 `--gateway 127.0.0.1:11111` (与默认值相等) 时被错判为 "未传" → TOML
215/// 配置 gateway override 反向. 违背 "CLI 始终覆盖配置文件" 契约.
216///
217/// 同模式: bool 字段 (`verbose` / `enable_trading` / `allow_real_trading`)
218/// 之前用 `if !self.field` 判, 用户显式 `--verbose` 时反复 = false 也不能
219/// 区分 "CLI 没传 + TOML 也没设" 与 "CLI 显式 false (无 --no-flag)". clap
220/// derive 不天然支持 `--no-*`, 所以 bool 字段的 explicit-false override 是
221/// 设计 limitation; 但 explicit-true 一定要尊重.
222///
223/// 本 helper 接受 `&ArgMatches` 与 `arg_id`, 返 true 仅在 user explicitly 传
224/// (而不是 default / env). 见
225/// <https://docs.rs/clap/latest/clap/parser/enum.ValueSource.html>.
226fn is_cli_explicit(matches: &ArgMatches, arg_id: &str) -> bool {
227    matches!(
228        matches.value_source(arg_id),
229        Some(ValueSource::CommandLine) | Some(ValueSource::EnvVariable)
230    )
231}
232
233impl Cli {
234    /// 如果指定了 `--config`,先从文件读取默认值,再让 CLI 参数覆盖。
235    ///
236    /// codex 0547 F5 (P3): 用 `&ArgMatches` 精准判断 "CLI/env 是否显式传"
237    /// 而非旧的 "值 == 默认 → 当作没传" 启发式. CLI 显式传 = 显式 (即使值
238    /// 等于默认). TOML 文件值仅在 CLI / env 都没显式传时才采用.
239    fn merge_config(mut self, matches: &ArgMatches) -> Result<Self> {
240        let Some(config_path) = &self.config else {
241            return Ok(self);
242        };
243        let content = std::fs::read_to_string(config_path)
244            .with_context(|| format!("read config file {}", config_path.display()))?;
245        let fc: FileConfig = toml::from_str(&content)
246            .with_context(|| format!("parse config file {}", config_path.display()))?;
247
248        // codex 0547 F5: gateway 用 ValueSource 精准判. 之前 "self.gateway ==
249        // 默认值" 启发式在用户**显式**传默认值时反向 (TOML 覆盖 CLI).
250        if let Some(g) = fc.gateway
251            && !is_cli_explicit(matches, "gateway")
252        {
253            self.gateway = g;
254        }
255        // codex 0547 F5: Option 字段用 None check (CLI 没传 = None, 不会与
256        // 默认值 ambiguity).
257        if self.keys_file.is_none() {
258            self.keys_file = fc.keys_file;
259        }
260        if self.api_key.is_none()
261            && let Some(k) = fc.api_key
262        {
263            self.api_key = Some(k);
264        }
265        if self.trade_pwd_account.is_none() {
266            self.trade_pwd_account = fc.trade_pwd_account;
267        }
268        // codex 0547 F5: bool 字段用 ValueSource 区分 "未传" vs "显式 false".
269        // clap derive 没 `--no-verbose`, 所以无法 explicit-set false; 但用户
270        // **显式 true** (e.g. `--verbose` 在 CLI) 要保留 (TOML 即使写 false
271        // 也不能覆盖 explicit-true).
272        if fc.verbose.is_some() && !is_cli_explicit(matches, "verbose") {
273            self.verbose = fc.verbose.unwrap_or(false);
274        }
275        if fc.enable_trading.is_some() && !is_cli_explicit(matches, "enable_trading") {
276            self.enable_trading = fc.enable_trading.unwrap_or(false);
277        }
278        if fc.allow_real_trading.is_some() && !is_cli_explicit(matches, "allow_real_trading") {
279            self.allow_real_trading = fc.allow_real_trading.unwrap_or(false);
280        }
281        if self.audit_log.is_none() {
282            self.audit_log = fc.audit_log;
283        }
284        if self.http_listen.is_none() {
285            self.http_listen = fc.http_listen;
286        }
287        if self.tls_cert.is_none() {
288            self.tls_cert = fc.tls_cert;
289        }
290        if self.tls_key.is_none() {
291            self.tls_key = fc.tls_key;
292        }
293        // 此时 tracing 可能还没初始化,写 stderr 即可
294        eprintln!("[config] loaded {}", config_path.display());
295        Ok(self)
296    }
297}
298
299#[tokio::main]
300async fn main() -> Result<()> {
301    // codex 0547 F5 (P3): 解析两次 — 用 ArgMatches 区分 explicit vs default
302    // 后再用 derive 反向 build Cli 结构. 单次 parse 走不通 (Cli::parse 不暴露
303    // ArgMatches), 但解析+from_arg_matches 是 0-allocation cycle.
304    let matches = Cli::command().get_matches();
305    let cli = Cli::from_arg_matches(&matches)
306        .map_err(|e| anyhow::anyhow!("clap derive build failed: {e}"))?
307        .merge_config(&matches)?;
308
309    // MCP 用 stdout 传协议帧,所有日志必须写 stderr
310    let default_level = if cli.verbose { "debug" } else { "info" };
311
312    // audit 日志 guard 必须活到 main 返回;否则后台 flush 可能丢事件
313    let _audit_guard = setup_logging(default_level, cli.audit_log.as_deref())?;
314
315    // ---------- 加载 KeyStore ----------
316    let key_store = match &cli.keys_file {
317        Some(path) => {
318            let store = KeyStore::load(path)
319                .with_context(|| format!("load keys file {}", path.display()))?;
320            tracing::info!(
321                path = %path.display(),
322                keys_loaded = store.len(),
323                "scope mode: keys file loaded"
324            );
325            if cli.enable_trading || cli.allow_real_trading {
326                tracing::warn!(
327                    "--enable-trading / --allow-real-trading are IGNORED in scope mode; \
328                     trading permissions are controlled by API key scopes"
329                );
330            }
331            Arc::new(store)
332        }
333        None => {
334            tracing::info!("legacy mode: no keys file; using --enable-trading switches");
335            Arc::new(KeyStore::empty())
336        }
337    };
338
339    // ---------- 校验调用方 API key ----------
340    let authed_key = if key_store.is_configured() {
341        match cli.api_key.as_deref() {
342            Some(plaintext) if !plaintext.is_empty() => match key_store.verify(plaintext) {
343                Some(rec) => {
344                    tracing::info!(
345                        key_id = %rec.id,
346                        scopes = ?rec.scopes.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
347                        "API key verified"
348                    );
349                    Some(rec)
350                }
351                None => {
352                    tracing::error!("FUTU_MCP_API_KEY does not match any key in keys.json");
353                    None
354                }
355            },
356            _ => {
357                tracing::warn!(
358                    "scope mode active but FUTU_MCP_API_KEY not set; \
359                     all tool calls will be rejected"
360                );
361                None
362            }
363        }
364    } else {
365        None
366    };
367
368    tracing::info!(
369        gateway = %cli.gateway,
370        scope_mode = key_store.is_configured(),
371        enable_trading = cli.enable_trading,
372        allow_real_trading = cli.allow_real_trading,
373        trade_pwd_account = cli.trade_pwd_account.as_deref().unwrap_or("<legacy/env>"),
374        "futu-mcp starting"
375    );
376    if !key_store.is_configured() && cli.enable_trading {
377        tracing::warn!(
378            allow_real_trading = cli.allow_real_trading,
379            "trading write tools ENABLED (legacy mode)"
380        );
381    }
382
383    let state = ServerState::new(cli.gateway)
384        .with_trading(cli.enable_trading, cli.allow_real_trading)
385        .with_key_store(key_store.clone())
386        .with_authed_key(authed_key)
387        .with_trade_pwd_account(cli.trade_pwd_account);
388    let server = FutuServer::new(state.clone());
389
390    // v1.4.105 eli #4 (BUG-v1.4.104-002, P1) fix: standalone MCP 启动时把
391    // `allowed_card_nums` (string format, e.g. ["0757"]) resolve 成
392    // `allowed_acc_ids` (numeric set), 行为与 futu-opend daemon 启动 +
393    // SIGHUP 路径**byte-identical** (resolver 4-suffix / 16-exact 双匹配
394    // card_num + uni_card_num, 1 个 → resolved, 0 → unresolved warn,
395    // ≥2 → ambiguous warn). 不做 expand 时 KeyStore::load_file 注入的
396    // fail-closed sentinel `allowed_acc_ids = {0}` 会让真账户 acc_id ≠ 0
397    // 永远 reject "not in allowed list {0}".
398    //
399    // 设计要点:
400    // - 仅在 KeyStore 至少一条 key 配置了 allowed_card_nums 时才连 daemon
401    //   (避免无意义 GetAccList 请求)
402    // - daemon 可能尚未起来 / connect race → 后台 task 重试 6 次 × 10s
403    //   覆盖 daemon 启动 ~60s 窗口, 与 daemon 内部 trd_cache 加载 retry 同节奏
404    // - expand 失败不阻塞 MCP server 启动 — sentinel 仍生效保护
405    // - SIGHUP 重载 keys.json 后必须重新 expand (sentinel/旧 acc_ids 会失效)
406    if key_store.is_configured() && key_store.has_any_card_num_restrictions() {
407        spawn_card_num_expand_retry(state.clone(), key_store.clone());
408    } else if key_store.is_configured() {
409        tracing::debug!("v1.4.105 eli #4: keystore 无 allowed_card_nums 限制, 跳过 daemon expand");
410    }
411
412    // SIGHUP 热重载 keys.json(unix only)— v1.4.105 eli #4: reload 后 +
413    // re-expand card_num (与 daemon `card_num_reload_and_expand_fn` 同语义)
414    #[cfg(unix)]
415    spawn_sighup_reload(key_store, state.clone());
416
417    // v1.0:install 全局 metrics registry,让 audit::* 的 counter hook 起作用
418    // (HTTP 模式下 /metrics 端点消费这套;stdio 模式虽然没 HTTP,但写进内存
419    // 方便 debug 和后续加 transport)
420    futu_auth::metrics::install(std::sync::Arc::new(futu_auth::MetricsRegistry::default()));
421
422    if let Some(listen) = cli.http_listen {
423        let tls = match (cli.tls_cert, cli.tls_key) {
424            (Some(cert), Some(key)) => Some((cert, key)),
425            _ => None,
426        };
427        serve_http(server, &listen, tls).await?;
428    } else {
429        serve_stdio(server).await?;
430    }
431
432    Ok(())
433}
434
435/// stdio 模式:MCP 客户端启动子进程,stdin/stdout 传协议帧
436///
437/// v1.4.90 P0-A: uses `resilient_stdio()` instead of `rmcp::transport::stdio()`
438/// — a malformed JSON line (e.g. `{"price": Infinity}` from an LLM client)
439/// now produces a `-32700 Parse error` response instead of `exit(0)`-ing the
440/// entire server. See crates/futu-mcp/src/transport.rs for full background.
441async fn serve_stdio(server: tools::FutuServer) -> Result<()> {
442    let service = server
443        .serve(resilient_stdio())
444        .await
445        .map_err(|e| anyhow::anyhow!("MCP service init failed: {e}"))?;
446
447    service
448        .waiting()
449        .await
450        .map_err(|e| anyhow::anyhow!("MCP service error: {e}"))?;
451    Ok(())
452}
453
454/// HTTP 模式:axum + rmcp StreamableHttpService,`/mcp` 路径跑 MCP,
455/// `/metrics` 暴露 Prometheus counters(无需 token),
456/// `/.well-known/oauth-protected-resource` 暴露 OAuth2 Protected Resource
457/// Metadata(RFC 9728,给 MCP 客户端发现鉴权要求用)。
458///
459/// v1.4+:未带 Bearer token 的 `/mcp` 请求会回 `401 + WWW-Authenticate`
460/// 头,指向 resource metadata,配合 rmcp 客户端的自动发现流程。
461fn render_mcp_metrics_body() -> String {
462    let registry = futu_auth::metrics::global();
463    render_mcp_metrics_body_for(registry.as_deref())
464}
465
466fn render_mcp_metrics_body_for(registry: Option<&futu_auth::MetricsRegistry>) -> String {
467    registry.map(|r| r.render_prometheus()).unwrap_or_else(|| {
468        concat!(
469            "# HELP futu_metrics_registry_installed Whether futu_auth metrics registry is installed (1=yes, 0=no)\n",
470            "# TYPE futu_metrics_registry_installed gauge\n",
471            "futu_metrics_registry_installed{state=\"metrics registry not installed\"} 0\n"
472        )
473        .to_string()
474    })
475}
476
477async fn serve_http(
478    server: tools::FutuServer,
479    listen: &str,
480    tls: Option<(PathBuf, PathBuf)>,
481) -> Result<()> {
482    use rmcp::transport::streamable_http_server::{
483        StreamableHttpService, session::local::LocalSessionManager,
484    };
485
486    // 补齐 `:port` 写法:bind 到 0.0.0.0:port
487    let bind_addr = if listen.starts_with(':') {
488        format!("0.0.0.0{listen}")
489    } else {
490        listen.to_string()
491    };
492
493    // rmcp StreamableHttpService 要求一个 service factory —— 每个 HTTP 会话要
494    // 一份独立的 MCP Service 实例。FutuServer 目前是 Clone 的(ServerState 内
495    // Arc 共享),所以 factory 里 clone 给出去就行
496    let session_manager = std::sync::Arc::new(LocalSessionManager::default());
497    let mcp_svc = StreamableHttpService::new(
498        {
499            let server = server.clone();
500            move || Ok::<_, std::io::Error>(server.clone())
501        },
502        session_manager,
503        Default::default(),
504    );
505
506    // axum 0.8 router:
507    // - /mcp        → MCP tower service(带 WWW-Authenticate 401 middleware)
508    // - /metrics    → Prometheus 文本
509    // - /.well-known/oauth-protected-resource → RFC 9728 元数据
510    use axum::routing::get;
511    let mcp_with_auth_hint = axum::Router::new()
512        .nest_service("/mcp", mcp_svc)
513        .layer(axum::middleware::from_fn(inject_www_authenticate));
514
515    let app = axum::Router::new()
516        .route(
517            "/metrics",
518            get(|| async {
519                let body = render_mcp_metrics_body();
520                (
521                    axum::http::StatusCode::OK,
522                    [(
523                        axum::http::header::CONTENT_TYPE,
524                        "text/plain; version=0.0.4",
525                    )],
526                    body,
527                )
528            }),
529        )
530        .route(
531            "/.well-known/oauth-protected-resource",
532            get(oauth_protected_resource_metadata),
533        )
534        .merge(mcp_with_auth_hint);
535
536    let bind_addr_sock: std::net::SocketAddr = bind_addr
537        .parse()
538        .map_err(|e| anyhow::anyhow!("invalid bind address {bind_addr}: {e}"))?;
539
540    if let Some((cert_path, key_path)) = tls {
541        // ---------- HTTPS(graceful shutdown 通过 Handle)----------
542        let tls_config =
543            axum_server::tls_rustls::RustlsConfig::from_pem_file(&cert_path, &key_path)
544                .await
545                .with_context(|| {
546                    format!(
547                        "load TLS cert={} key={}",
548                        cert_path.display(),
549                        key_path.display()
550                    )
551                })?;
552        let handle = axum_server::Handle::new();
553        let shutdown_handle = handle.clone();
554        tokio::spawn(async move {
555            shutdown_signal().await;
556            tracing::info!("graceful shutdown: draining HTTPS connections...");
557            shutdown_handle.graceful_shutdown(Some(std::time::Duration::from_secs(10)));
558        });
559        tracing::info!(
560            addr = %bind_addr,
561            cert = %cert_path.display(),
562            "futu-mcp HTTPS transport started \
563             (MCP: /mcp, metrics: /metrics, OAuth metadata: /.well-known/oauth-protected-resource)"
564        );
565        axum_server::bind_rustls(bind_addr_sock, tls_config)
566            .handle(handle)
567            .serve(app.into_make_service())
568            .await
569            .map_err(|e| anyhow::anyhow!("axum-server TLS serve error: {e}"))?;
570    } else {
571        // ---------- plain HTTP(graceful shutdown 通过 axum::serve)----------
572        let listener = tokio::net::TcpListener::bind(&bind_addr)
573            .await
574            .map_err(|e| anyhow::anyhow!("bind {bind_addr}: {e}"))?;
575        tracing::info!(
576            addr = %bind_addr,
577            "futu-mcp HTTP transport started \
578             (MCP: /mcp, metrics: /metrics, OAuth metadata: /.well-known/oauth-protected-resource)"
579        );
580        axum::serve(listener, app)
581            .with_graceful_shutdown(async {
582                shutdown_signal().await;
583                tracing::info!("graceful shutdown: draining HTTP connections...");
584            })
585            .await
586            .map_err(|e| anyhow::anyhow!("axum serve error: {e}"))?;
587    }
588    tracing::info!("server stopped");
589    Ok(())
590}
591
592/// 监听 SIGTERM / SIGINT,任一到达即返回。
593/// 同时兼容 Windows(只有 ctrl_c)和 Unix(SIGTERM + SIGINT)。
594async fn shutdown_signal() {
595    #[cfg(unix)]
596    {
597        use tokio::signal::unix::{SignalKind, signal};
598        let sigterm = match signal(SignalKind::terminate()) {
599            Ok(signal) => Some(signal),
600            Err(e) => {
601                tracing::error!(error = %e, "failed to install SIGTERM handler");
602                None
603            }
604        };
605        let sigint = match signal(SignalKind::interrupt()) {
606            Ok(signal) => Some(signal),
607            Err(e) => {
608                tracing::error!(error = %e, "failed to install SIGINT handler");
609                None
610            }
611        };
612
613        match (sigterm, sigint) {
614            (Some(mut sigterm), Some(mut sigint)) => {
615                tokio::select! {
616                    _ = sigterm.recv() => tracing::info!("received SIGTERM"),
617                    _ = sigint.recv()  => tracing::info!("received SIGINT"),
618                }
619            }
620            (Some(mut sigterm), None) => {
621                sigterm.recv().await;
622                tracing::info!("received SIGTERM");
623            }
624            (None, Some(mut sigint)) => {
625                sigint.recv().await;
626                tracing::info!("received SIGINT");
627            }
628            (None, None) => wait_for_ctrl_c_or_pending().await,
629        }
630    }
631    #[cfg(not(unix))]
632    {
633        wait_for_ctrl_c_or_pending().await;
634    }
635}
636
637async fn wait_for_ctrl_c_or_pending() {
638    match tokio::signal::ctrl_c().await {
639        Ok(()) => tracing::info!("received Ctrl-C"),
640        Err(e) => {
641            tracing::error!(
642                error = %e,
643                "failed to install ctrl-c handler; graceful shutdown signal unavailable"
644            );
645            std::future::pending::<()>().await;
646        }
647    }
648}
649
650/// RFC 9728 — OAuth 2.0 Protected Resource Metadata
651///
652/// 我们不是完整 OAuth 授权服务器(那需要独立的 IdP),只是告诉 MCP 客户端
653/// "这个资源要 Bearer token,scope 列表如下"。LLM agent 实际拿到 key 的方式
654/// 仍然是运维线下发放 + 写入 MCP client 配置里的 `Authorization` 头。
655///
656/// 客户端可以 GET `/.well-known/oauth-protected-resource` 来发现:
657///   - `resource`:              本 MCP endpoint URI
658///   - `bearer_methods_supported`: 我们只支持 `header`(Authorization: Bearer ...)
659///   - `scopes_supported`:      可声明的 futu-auth scope 列表
660///   - `resource_name` / `resource_documentation`: 给人看的说明
661async fn oauth_protected_resource_metadata() -> axum::response::Json<serde_json::Value> {
662    axum::response::Json(serde_json::json!({
663        "resource": "/mcp",
664        "bearer_methods_supported": ["header"],
665        "scopes_supported": [
666            "qot:read",
667            "acc:read",
668            "trade:simulate",
669            "trade:real",
670            "trade:unlock"
671        ],
672        "resource_name": "FutuOpenD-rs MCP",
673        "resource_documentation": "https://futuapi.com/reference/mcp/",
674    }))
675}
676
677/// Tower middleware: 如果下游(MCP service)返回 401/403 又没 `WWW-Authenticate`
678/// 头,补一个 `Bearer resource_metadata="..."`,指向 `/.well-known/oauth-protected-resource`。
679///
680/// 符合 RFC 9728 §5.1:资源服务器应通过 WWW-Authenticate 宣告 metadata URL,
681/// 让未配置的客户端能自动发现 scope 和鉴权方式。
682async fn inject_www_authenticate(
683    req: axum::extract::Request,
684    next: axum::middleware::Next,
685) -> axum::response::Response {
686    let mut resp = next.run(req).await;
687    let status = resp.status();
688    if (status == axum::http::StatusCode::UNAUTHORIZED
689        || status == axum::http::StatusCode::FORBIDDEN)
690        && !resp
691            .headers()
692            .contains_key(axum::http::header::WWW_AUTHENTICATE)
693    {
694        // 相对路径 —— 客户端按 Host header 拼全 URL;也避免 TLS 终止在前置
695        // 反代(Caddy / Nginx)时我们误把内网地址写进响应头
696        let value = axum::http::HeaderValue::from_static(
697            "Bearer resource_metadata=\"/.well-known/oauth-protected-resource\"",
698        );
699        resp.headers_mut()
700            .insert(axum::http::header::WWW_AUTHENTICATE, value);
701    }
702    resp
703}
704
705#[cfg(unix)]
706fn spawn_sighup_reload(store: Arc<KeyStore>, state: ServerState) {
707    if !store.is_configured() {
708        return;
709    }
710    use tokio::signal::unix::{SignalKind, signal};
711    tokio::spawn(async move {
712        let mut sig = match signal(SignalKind::hangup()) {
713            Ok(s) => s,
714            Err(e) => {
715                tracing::error!(error = %e, "failed to install SIGHUP handler");
716                return;
717            }
718        };
719        tracing::info!("SIGHUP handler installed; send `kill -HUP <pid>` to reload keys");
720        while sig.recv().await.is_some() {
721            // (1) reload phase — KeyStore::load_file 重新读盘 + 重新注入
722            // fail-closed sentinel for any new allowed_card_nums entries
723            match store.reload() {
724                Ok(()) => tracing::warn!(keys_loaded = store.len(), "keys file reloaded on SIGHUP"),
725                Err(e) => {
726                    tracing::error!(error = %e, "SIGHUP reload failed; keeping old keys");
727                    continue;
728                }
729            }
730            // (2) expand phase — v1.4.105 eli #4: reload 后 raw allowed_card_nums
731            // 已经回到字符串状态 (sentinel acc_id=0 已重新写入), 必须再跑一次
732            // expand 把 card_num resolve 成 acc_ids; 否则 keys.json 修改后受
733            // 影响的 key 全部回到 fail-closed reject (即使 daemon 已起 +
734            // GetAccList 缓存可用). 与 daemon 的 unified SIGHUP handler 同语义.
735            if store.has_any_card_num_restrictions() {
736                let store_clone = store.clone();
737                let state_clone = state.clone();
738                tokio::spawn(async move {
739                    if let Err(e) = expand_card_nums_via_daemon(&state_clone, &store_clone).await {
740                        tracing::warn!(
741                            error = %e,
742                            "v1.4.105 eli #4: SIGHUP re-expand failed; sentinel 仍生效保护"
743                        );
744                    }
745                });
746            }
747        }
748    });
749}
750
751/// v1.4.105 eli #4 fix: 启动时后台 retry 把 KeyStore 的
752/// `allowed_card_nums` resolve 成 `allowed_acc_ids`. 与 futu-opend daemon
753/// `card_num_reload_and_expand_fn` 启动 retry loop 同节奏 (6 × 10s).
754///
755/// 失败模式:
756/// - daemon 未起来 → connect error → 等 10s 重试
757/// - daemon 起来但 GetAccList 失败 (尚未登录 / 无账户) → 等 10s 重试
758/// - 6 次都失败 → 放弃 (sentinel `{0}` 仍生效保护)
759fn spawn_card_num_expand_retry(state: ServerState, key_store: Arc<KeyStore>) {
760    tokio::spawn(async move {
761        const MAX_ATTEMPTS: u32 = 6;
762        const RETRY_INTERVAL_SECS: u64 = 10;
763        for attempt in 1..=MAX_ATTEMPTS {
764            match expand_card_nums_via_daemon(&state, &key_store).await {
765                Ok(()) => {
766                    tracing::info!(
767                        attempt,
768                        "v1.4.105 eli #4: standalone MCP allowed_card_nums expanded \
769                         (与 daemon expand 路径 byte-identical)"
770                    );
771                    return;
772                }
773                Err(e) => {
774                    if attempt < MAX_ATTEMPTS {
775                        tracing::warn!(
776                            attempt,
777                            max = MAX_ATTEMPTS,
778                            error = %e,
779                            "v1.4.105 eli #4: card_num expand 失败, {RETRY_INTERVAL_SECS}s 后重试"
780                        );
781                        tokio::time::sleep(std::time::Duration::from_secs(RETRY_INTERVAL_SECS))
782                            .await;
783                    } else {
784                        tracing::error!(
785                            attempt,
786                            error = %e,
787                            "v1.4.105 eli #4: card_num expand 在 {MAX_ATTEMPTS} × \
788                             {RETRY_INTERVAL_SECS}s 后仍失败; 受限 key 走 fail-closed \
789                             sentinel reject 直到下次 SIGHUP / 手动 reload"
790                        );
791                    }
792                }
793            }
794        }
795    });
796}
797
798/// v1.4.105 eli #4 fix: 连 daemon → call GetAccList → 用 acc_list 构造 resolver →
799/// 调用 [`KeyStore::expand_allowed_card_nums`].
800///
801/// 这是 daemon `card_num_reload_and_expand_fn` 在 standalone MCP 进程的镜像.
802/// daemon 那边是从 `Arc<TrdCache>` 取已缓存的账户列表; MCP 没有 cache, 必须
803/// 主动调 `TRD_GetAccList` 拿 fresh data.
804async fn expand_card_nums_via_daemon(state: &ServerState, key_store: &Arc<KeyStore>) -> Result<()> {
805    // 1. 连 daemon (state.client() 懒加载 — 第一次会建立 TCP + InitConnect)
806    let client = state
807        .client()
808        .await
809        .with_context(|| "connect to daemon for card_num expand")?;
810
811    // 2. 拉 GetAccList — daemon 必须已成功登录拿到账户. 若 user 还没 unlock /
812    //    daemon 还在 establish session, 这里会返 server error (ret_type ≠ 0)
813    let accs = futu_trd::account::get_acc_list_for_account_discovery(&client)
814        .await
815        .with_context(|| "GetAccList for card_num expand")?;
816
817    if accs.is_empty() {
818        return Err(anyhow::anyhow!(
819            "GetAccList returned empty list (daemon 已起但无账户?)"
820        ));
821    }
822
823    // 3. 用 acc_list 构造 resolver. 行为与
824    //    `crates/futu-cache/src/trd_cache.rs::find_acc_ids_by_card_num`
825    //    byte-identical (4-suffix / 16-exact 双匹配 card_num + uni_card_num,
826    //    sort + dedup).
827    let resolver = build_card_num_resolver(accs);
828
829    // 4. expand — 与 daemon `card_num_reload_and_expand_fn` 用同一套 callback
830    //    语义 (warn unresolved / ambiguous, 写 sentinel 0 让 fail-closed)
831    let (resolved, unresolved, ambiguous) = key_store.expand_allowed_card_nums(
832        &resolver,
833        |key_id, cn| {
834            tracing::warn!(
835                key_id = %key_id,
836                card_num = %cn,
837                "v1.4.105 eli #4 fail-closed: card_num not found in daemon GetAccList; \
838                 sentinel acc_id=0 让限额引擎 reject 真账户 (写完整 16 位 / specific 4 位)"
839            );
840        },
841        |key_id, cn, candidates| {
842            tracing::warn!(
843                key_id = %key_id,
844                card_num = %cn,
845                candidates = ?candidates,
846                "v1.4.105 eli #4 fail-closed: ambiguous card_num suffix matched 多账户 \
847                 (skipped; 写完整 16 位 / specific 4 位)"
848            );
849        },
850    );
851
852    tracing::info!(
853        resolved,
854        unresolved,
855        ambiguous,
856        "v1.4.105 eli #4: standalone MCP allowed_card_nums expanded into allowed_acc_ids"
857    );
858    Ok(())
859}
860
861/// v1.4.105 eli #4 fix: 用 `Vec<TrdAcc>` 构造 card_num resolver closure.
862///
863/// v1.4.109 Phase C: 解析规则下沉到 `futu_core::account_locator`,MCP
864/// standalone / REST / CLI 不再各自手写 4 位 suffix、16 位完整卡号和
865/// `card_num` / `uni_card_num` OR 关系。
866fn build_card_num_resolver(accs: Vec<futu_trd::TrdAcc>) -> impl Fn(&str) -> Vec<u64> {
867    move |input: &str| -> Vec<u64> {
868        futu_core::account_locator::match_card_num_in_records(&accs, input, None)
869            .unwrap_or_default()
870    }
871}
872
873#[cfg(test)]
874mod tests;