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