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 http;
17mod state;
18mod tool_account;
19mod tool_args;
20mod tool_auth;
21mod tool_enums;
22mod tools;
23mod trade_pwd;
24// v1.4.90 P0-A: resilient stdio transport — recovers from malformed JSON
25// (e.g. `{"price": Infinity}`) instead of `exit(0)`-ing the whole server.
26// See crates/futu-mcp/src/transport.rs for full rationale.
27mod transport;
28
29use std::path::PathBuf;
30use std::sync::Arc;
31
32use anyhow::{Context, Result};
33use clap::{ArgMatches, CommandFactory, FromArgMatches, Parser, parser::ValueSource};
34use futu_auth::KeyStore;
35use rmcp::ServiceExt;
36// v1.4.90 P0-A: stdio() (rmcp default) treats parse errors as fatal — see transport.rs.
37use crate::transport::resilient_stdio;
38use tracing_subscriber::{
39    EnvFilter, Layer, filter::filter_fn, fmt, layer::SubscriberExt, util::SubscriberInitExt,
40};
41
42#[cfg(test)]
43pub(crate) use crate::card_num_expand::build_card_num_resolver;
44use crate::card_num_expand::spawn_card_num_expand_retry;
45#[cfg(unix)]
46use crate::card_num_expand::spawn_sighup_reload;
47use crate::http::serve_http;
48#[cfg(test)]
49use crate::http::{
50    inject_www_authenticate, oauth_protected_resource_metadata, render_mcp_metrics_body_for,
51};
52use crate::state::ServerState;
53use crate::tools::FutuServer;
54
55/// 初始化 stderr 日志 + 可选 audit JSONL 层
56///
57/// - 常规事件走 stderr(no-ansi,因为 MCP client 的 stderr 往往不是 tty)
58/// - 如果 `audit_path` 传了,加一个 target=futu_audit 的 JSON 层写到文件/目录
59fn setup_logging(
60    default_level: &str,
61    audit_path: Option<&std::path::Path>,
62) -> Result<Option<tracing_appender::non_blocking::WorkerGuard>> {
63    let filter =
64        EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level));
65
66    let fmt_layer = fmt::layer()
67        .with_timer(futu_core::log::LocalRfc3339Timer)
68        .with_writer(std::io::stderr)
69        .with_ansi(false);
70
71    let registry = tracing_subscriber::registry().with(filter).with(fmt_layer);
72
73    if let Some(path) = audit_path {
74        let (writer, guard) = futu_auth::audit::open_writer(path)
75            .with_context(|| format!("open audit log {}", path.display()))?;
76        let audit_layer = fmt::layer()
77            .json()
78            .with_timer(futu_core::log::LocalRfc3339Timer)
79            .flatten_event(true)
80            .with_current_span(false)
81            .with_span_list(false)
82            .with_target(true)
83            .with_writer(writer)
84            .with_filter(filter_fn(|meta| meta.target() == futu_auth::audit::TARGET));
85        registry.with(audit_layer).init();
86        tracing::info!(
87            path = %path.display(),
88            "audit JSONL logger enabled (target=futu_audit)"
89        );
90        Ok(Some(guard))
91    } else {
92        registry.init();
93        Ok(None)
94    }
95}
96
97/// FutuOpenD-rs MCP server
98#[derive(Parser)]
99#[command(
100    name = "futu-mcp",
101    version,
102    about = "FutuOpenD-rs MCP server",
103    long_about = "通过 Model Context Protocol 暴露 Futu 行情/账户工具。默认 stdio transport。"
104)]
105struct Cli {
106    /// 网关地址(可用 FUTU_GATEWAY 环境变量覆盖)
107    #[arg(short, long, env = "FUTU_GATEWAY", default_value = "127.0.0.1:11111")]
108    gateway: String,
109
110    /// 启用 debug 日志
111    #[arg(short, long)]
112    verbose: bool,
113
114    /// Scope 模式:加载 keys.json 文件(API Key 授权)。
115    ///
116    /// 启用后所有工具调用必须带 FUTU_MCP_API_KEY 环境变量,
117    /// scope / 限额由 keys.json 配置决定;此时 --enable-trading / --allow-real-trading 被忽略。
118    #[arg(long)]
119    keys_file: Option<PathBuf>,
120
121    /// 调用方 API Key 明文(等价于 FUTU_MCP_API_KEY 环境变量)
122    ///
123    /// 生产环境强烈建议用环境变量而非命令行参数(后者会进 `ps` 输出)。
124    #[arg(long, env = "FUTU_MCP_API_KEY", hide_env_values = true)]
125    api_key: Option<String>,
126
127    /// 交易密码所属登录账号,用于读取账号级 keychain 条目。
128    ///
129    /// 对应 `futucli set-trade-pwd --account <login-account>` 写入的
130    /// `trade-password.<login-account>`。读取优先级为 FUTU_TRADE_PWD
131    /// (trim 后非空)> 账号级 keychain > 旧全局 keychain;未设置账号时会尝试
132    /// FUTU_ACCOUNT 作为账号 hint。
133    #[arg(long, env = "FUTU_TRADE_PWD_ACCOUNT")]
134    trade_pwd_account: Option<String>,
135
136    /// [Legacy] 启用交易写工具(place / modify / cancel)。默认关闭。
137    ///
138    /// 开启后默认仅允许 simulate 环境;要操作真实账户需额外 --allow-real-trading。
139    /// 注意:下单前网关必须已 unlock_trade(密码不经过 MCP / LLM)。
140    /// 若提供了 --keys-file,此开关被忽略,改由 key 的 scope 决定。
141    #[arg(long)]
142    enable_trading: bool,
143
144    /// [Legacy] 允许交易写工具对 real 环境执行。必须与 --enable-trading 搭配。
145    #[arg(long, requires = "enable_trading")]
146    allow_real_trading: bool,
147
148    /// 审计日志输出:JSONL 文件路径或目录
149    ///
150    /// - 带扩展名(`/var/log/futu-mcp-audit.jsonl`)→ 单文件 append
151    /// - 不带扩展名 / 以 `/` 结尾 → 每日滚动 `futu-audit.log` + 日期
152    ///
153    /// 只记录 auth / 交易 事件(target = `futu_audit`)。
154    #[arg(long)]
155    audit_log: Option<PathBuf>,
156
157    /// 以 HTTP transport 启动(streamable HTTP),监听该端口(格式 `host:port` 或 `:port`)
158    ///
159    /// 默认 stdio:LLM 客户端启子进程走 stdin/stdout。开 HTTP 后可以让多个
160    /// 客户端连同一个 MCP 进程,并同时暴露 `/metrics`。per-call key 覆盖依然
161    /// 走 tool args 的 `api_key` 字段;HTTP-layer 的 Authorization header 未来
162    /// 版本再接(v1.0 先做传输层切换)。
163    ///
164    /// 例:`--http-listen 127.0.0.1:3000` / `--http-listen :3000`
165    #[arg(long)]
166    http_listen: Option<String>,
167
168    /// TLS 证书文件路径(PEM 格式;需与 --tls-key 配合)
169    ///
170    /// 启用后 HTTP transport 走 HTTPS。若不设置,走纯 HTTP(建议前置 Caddy / Nginx
171    /// 做 TLS 终止)。
172    #[arg(long, requires = "tls_key")]
173    tls_cert: Option<PathBuf>,
174
175    /// TLS 私钥文件路径(PEM 格式;需与 --tls-cert 配合)
176    #[arg(long, requires = "tls_cert")]
177    tls_key: Option<PathBuf>,
178
179    /// TOML 配置文件路径(字段名与 CLI 参数一致,CLI 参数覆盖配置文件)
180    ///
181    /// 示例:
182    /// ```toml
183    /// gateway = "10.0.0.1:11111"
184    /// http_listen = ":3000"
185    /// keys_file = "/etc/futu/keys.json"
186    /// audit_log = "/var/log/futu-mcp-audit.jsonl"
187    /// tls_cert = "/etc/futu/cert.pem"
188    /// tls_key  = "/etc/futu/key.pem"
189    /// ```
190    #[arg(long)]
191    config: Option<PathBuf>,
192}
193
194/// TOML 配置文件映射——字段名与 CLI 参数完全一致
195///
196/// codex 0547 F4 (P2) fix: 加 `#[serde(deny_unknown_fields)]` — 与 `futu-opend`
197/// XmlConfig (BUG-006 v1.4.102 加的) 同语义级别. typo (e.g. `keys_flie` /
198/// `auditlog`) 之前 silent drop, 用户配置 silent 失效:
199/// - `key_file` typo → keystore 不加载 → MCP 进 legacy mode (无 scope)
200/// - `http_litsen` typo → HTTP transport 不启动 (默认 stdio)
201/// - `auditlog` typo → 审计文件无事件
202///
203/// 修后: 任何 unknown field / typo / `[server]` 类未支持 section 立即 parse
204/// fatal, daemon abort + 清晰错误.
205///
206/// **不 break 老 deprecated alias**: 没有 alias 历史, MCP TOML schema 自 v1.0
207/// 起字段名稳定, 升级用户无 typo 不会被影响.
208#[derive(Debug, Default, serde::Deserialize)]
209#[serde(default, deny_unknown_fields)]
210struct FileConfig {
211    gateway: Option<String>,
212    verbose: Option<bool>,
213    keys_file: Option<PathBuf>,
214    api_key: Option<String>,
215    trade_pwd_account: Option<String>,
216    enable_trading: Option<bool>,
217    allow_real_trading: Option<bool>,
218    audit_log: Option<PathBuf>,
219    http_listen: Option<String>,
220    tls_cert: Option<PathBuf>,
221    tls_key: Option<PathBuf>,
222}
223
224/// codex 0547 F5 (P3) fix: clap `ValueSource` 区分 "CLI 显式传" vs "默认值".
225///
226/// 之前用 `self.gateway == "127.0.0.1:11111"` 判 "CLI 没传" — 当用户**显式**
227/// 传 `--gateway 127.0.0.1:11111` (与默认值相等) 时被错判为 "未传" → TOML
228/// 配置 gateway override 反向. 违背 "CLI 始终覆盖配置文件" 契约.
229///
230/// 同模式: bool 字段 (`verbose` / `enable_trading` / `allow_real_trading`)
231/// 之前用 `if !self.field` 判, 用户显式 `--verbose` 时反复 = false 也不能
232/// 区分 "CLI 没传 + TOML 也没设" 与 "CLI 显式 false (无 --no-flag)". clap
233/// derive 不天然支持 `--no-*`, 所以 bool 字段的 explicit-false override 是
234/// 设计 limitation; 但 explicit-true 一定要尊重.
235///
236/// 本 helper 接受 `&ArgMatches` 与 `arg_id`, 返 true 仅在 user explicitly 传
237/// (而不是 default / env). 见
238/// <https://docs.rs/clap/latest/clap/parser/enum.ValueSource.html>.
239fn is_cli_explicit(matches: &ArgMatches, arg_id: &str) -> bool {
240    matches!(
241        matches.value_source(arg_id),
242        Some(ValueSource::CommandLine) | Some(ValueSource::EnvVariable)
243    )
244}
245
246impl Cli {
247    /// 如果指定了 `--config`,先从文件读取默认值,再让 CLI 参数覆盖。
248    ///
249    /// codex 0547 F5 (P3): 用 `&ArgMatches` 精准判断 "CLI/env 是否显式传"
250    /// 而非旧的 "值 == 默认 → 当作没传" 启发式. CLI 显式传 = 显式 (即使值
251    /// 等于默认). TOML 文件值仅在 CLI / env 都没显式传时才采用.
252    fn merge_config(mut self, matches: &ArgMatches) -> Result<Self> {
253        let Some(config_path) = &self.config else {
254            return Ok(self);
255        };
256        let content = std::fs::read_to_string(config_path)
257            .with_context(|| format!("read config file {}", config_path.display()))?;
258        let fc: FileConfig = toml::from_str(&content)
259            .with_context(|| format!("parse config file {}", config_path.display()))?;
260
261        // codex 0547 F5: gateway 用 ValueSource 精准判. 之前 "self.gateway ==
262        // 默认值" 启发式在用户**显式**传默认值时反向 (TOML 覆盖 CLI).
263        if let Some(g) = fc.gateway
264            && !is_cli_explicit(matches, "gateway")
265        {
266            self.gateway = g;
267        }
268        // codex 0547 F5: Option 字段用 None check (CLI 没传 = None, 不会与
269        // 默认值 ambiguity).
270        if self.keys_file.is_none() {
271            self.keys_file = fc.keys_file;
272        }
273        if self.api_key.is_none()
274            && let Some(k) = fc.api_key
275        {
276            self.api_key = Some(k);
277        }
278        if self.trade_pwd_account.is_none() {
279            self.trade_pwd_account = fc.trade_pwd_account;
280        }
281        // codex 0547 F5: bool 字段用 ValueSource 区分 "未传" vs "显式 false".
282        // clap derive 没 `--no-verbose`, 所以无法 explicit-set false; 但用户
283        // **显式 true** (e.g. `--verbose` 在 CLI) 要保留 (TOML 即使写 false
284        // 也不能覆盖 explicit-true).
285        if fc.verbose.is_some() && !is_cli_explicit(matches, "verbose") {
286            self.verbose = fc.verbose.unwrap_or(false);
287        }
288        if fc.enable_trading.is_some() && !is_cli_explicit(matches, "enable_trading") {
289            self.enable_trading = fc.enable_trading.unwrap_or(false);
290        }
291        if fc.allow_real_trading.is_some() && !is_cli_explicit(matches, "allow_real_trading") {
292            self.allow_real_trading = fc.allow_real_trading.unwrap_or(false);
293        }
294        if self.audit_log.is_none() {
295            self.audit_log = fc.audit_log;
296        }
297        if self.http_listen.is_none() {
298            self.http_listen = fc.http_listen;
299        }
300        if self.tls_cert.is_none() {
301            self.tls_cert = fc.tls_cert;
302        }
303        if self.tls_key.is_none() {
304            self.tls_key = fc.tls_key;
305        }
306        // 此时 tracing 可能还没初始化,写 stderr 即可
307        eprintln!("[config] loaded {}", config_path.display());
308        Ok(self)
309    }
310}
311
312#[tokio::main]
313async fn main() -> Result<()> {
314    // codex 0547 F5 (P3): 解析两次 — 用 ArgMatches 区分 explicit vs default
315    // 后再用 derive 反向 build Cli 结构. 单次 parse 走不通 (Cli::parse 不暴露
316    // ArgMatches), 但解析+from_arg_matches 是 0-allocation cycle.
317    let matches = Cli::command().get_matches();
318    let cli = Cli::from_arg_matches(&matches)
319        .map_err(|e| anyhow::anyhow!("clap derive build failed: {e}"))?
320        .merge_config(&matches)?;
321
322    // MCP 用 stdout 传协议帧,所有日志必须写 stderr
323    let default_level = if cli.verbose { "debug" } else { "info" };
324
325    // audit 日志 guard 必须活到 main 返回;否则后台 flush 可能丢事件
326    let _audit_guard = setup_logging(default_level, cli.audit_log.as_deref())?;
327
328    // ---------- 加载 KeyStore ----------
329    let key_store = match &cli.keys_file {
330        Some(path) => {
331            let store = KeyStore::load(path)
332                .with_context(|| format!("load keys file {}", path.display()))?;
333            tracing::info!(
334                path = %path.display(),
335                keys_loaded = store.len(),
336                "scope mode: keys file loaded"
337            );
338            if cli.enable_trading || cli.allow_real_trading {
339                tracing::warn!(
340                    "--enable-trading / --allow-real-trading are IGNORED in scope mode; \
341                     trading permissions are controlled by API key scopes"
342                );
343            }
344            Arc::new(store)
345        }
346        None => {
347            tracing::info!("legacy mode: no keys file; using --enable-trading switches");
348            Arc::new(KeyStore::empty())
349        }
350    };
351
352    // ---------- 校验调用方 API key ----------
353    let authed_key = if key_store.is_configured() {
354        match cli.api_key.as_deref() {
355            Some(plaintext) if !plaintext.is_empty() => match key_store.verify(plaintext) {
356                Some(rec) => {
357                    tracing::info!(
358                        key_id = %rec.id,
359                        scopes = ?rec.scopes.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
360                        "API key verified"
361                    );
362                    Some(rec)
363                }
364                None => {
365                    tracing::error!("FUTU_MCP_API_KEY does not match any key in keys.json");
366                    None
367                }
368            },
369            _ => {
370                tracing::warn!(
371                    "scope mode active but FUTU_MCP_API_KEY not set; \
372                     all tool calls will be rejected"
373                );
374                None
375            }
376        }
377    } else {
378        None
379    };
380
381    tracing::info!(
382        gateway = %cli.gateway,
383        scope_mode = key_store.is_configured(),
384        enable_trading = cli.enable_trading,
385        allow_real_trading = cli.allow_real_trading,
386        trade_pwd_account = cli.trade_pwd_account.as_deref().unwrap_or("<legacy/env>"),
387        "futu-mcp starting"
388    );
389    if !key_store.is_configured() && cli.enable_trading {
390        tracing::warn!(
391            allow_real_trading = cli.allow_real_trading,
392            "trading write tools ENABLED (legacy mode)"
393        );
394    }
395
396    let state = ServerState::new(cli.gateway)
397        .with_trading(cli.enable_trading, cli.allow_real_trading)
398        .with_key_store(key_store.clone())
399        .with_authed_key(authed_key)
400        .with_trade_pwd_account(cli.trade_pwd_account);
401    let server = FutuServer::new(state.clone());
402
403    // v1.4.105 external reviewer #4 (BUG-v1.4.104-002, P1) fix: standalone MCP 启动时把
404    // `allowed_card_nums` (string format, e.g. ["0757"]) resolve 成
405    // `allowed_acc_ids` (numeric set), 行为与 futu-opend daemon 启动 +
406    // SIGHUP 路径**byte-identical** (resolver 4-suffix / 16-exact 双匹配
407    // card_num + uni_card_num, 1 个 → resolved, 0 → unresolved warn,
408    // ≥2 → ambiguous warn). 不做 expand 时 KeyStore::load_file 注入的
409    // fail-closed sentinel `allowed_acc_ids = {0}` 会让真账户 acc_id ≠ 0
410    // 永远 reject "not in allowed list {0}".
411    //
412    // 设计要点:
413    // - 仅在 KeyStore 至少一条 key 配置了 allowed_card_nums 时才连 daemon
414    //   (避免无意义 GetAccList 请求)
415    // - daemon 可能尚未起来 / connect race → 后台 task 重试 6 次 × 10s
416    //   覆盖 daemon 启动 ~60s 窗口, 与 daemon 内部 trd_cache 加载 retry 同节奏
417    // - expand 失败不阻塞 MCP server 启动 — sentinel 仍生效保护
418    // - SIGHUP 重载 keys.json 后必须重新 expand (sentinel/旧 acc_ids 会失效)
419    if key_store.is_configured() && key_store.has_any_card_num_restrictions() {
420        spawn_card_num_expand_retry(state.clone(), key_store.clone());
421    } else if key_store.is_configured() {
422        tracing::debug!(
423            "v1.4.105 external report #4: keystore 无 allowed_card_nums 限制, 跳过 daemon expand"
424        );
425    }
426
427    // SIGHUP 热重载 keys.json(unix only)— v1.4.105 external reviewer #4: reload 后 +
428    // re-expand card_num (与 daemon `card_num_reload_and_expand_fn` 同语义)
429    #[cfg(unix)]
430    spawn_sighup_reload(key_store, state.clone());
431
432    // v1.0:install 全局 metrics registry,让 audit::* 的 counter hook 起作用
433    // (HTTP 模式下 /metrics 端点消费这套;stdio 模式虽然没 HTTP,但写进内存
434    // 方便 debug 和后续加 transport)
435    futu_auth::metrics::install(std::sync::Arc::new(futu_auth::MetricsRegistry::default()));
436
437    if let Some(listen) = cli.http_listen {
438        let tls = match (cli.tls_cert, cli.tls_key) {
439            (Some(cert), Some(key)) => Some((cert, key)),
440            _ => None,
441        };
442        serve_http(server, &listen, tls).await?;
443    } else {
444        serve_stdio(server).await?;
445    }
446
447    Ok(())
448}
449
450/// stdio 模式:MCP 客户端启动子进程,stdin/stdout 传协议帧
451///
452/// v1.4.90 P0-A: uses `resilient_stdio()` instead of `rmcp::transport::stdio()`
453/// — a malformed JSON line (e.g. `{"price": Infinity}` from an LLM client)
454/// now produces a `-32700 Parse error` response instead of `exit(0)`-ing the
455/// entire server. See crates/futu-mcp/src/transport.rs for full background.
456async fn serve_stdio(server: tools::FutuServer) -> Result<()> {
457    let service = server
458        .serve(resilient_stdio())
459        .await
460        .map_err(|e| anyhow::anyhow!("MCP service init failed: {e}"))?;
461
462    service
463        .waiting()
464        .await
465        .map_err(|e| anyhow::anyhow!("MCP service error: {e}"))?;
466    Ok(())
467}
468
469#[cfg(test)]
470mod tests;