1mod 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;
24mod 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;
36use 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
55fn 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#[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 #[arg(short, long, env = "FUTU_GATEWAY", default_value = "127.0.0.1:11111")]
108 gateway: String,
109
110 #[arg(short, long)]
112 verbose: bool,
113
114 #[arg(long)]
119 keys_file: Option<PathBuf>,
120
121 #[arg(long, env = "FUTU_MCP_API_KEY", hide_env_values = true)]
125 api_key: Option<String>,
126
127 #[arg(long, env = "FUTU_TRADE_PWD_ACCOUNT")]
134 trade_pwd_account: Option<String>,
135
136 #[arg(long)]
142 enable_trading: bool,
143
144 #[arg(long, requires = "enable_trading")]
146 allow_real_trading: bool,
147
148 #[arg(long)]
155 audit_log: Option<PathBuf>,
156
157 #[arg(long)]
166 http_listen: Option<String>,
167
168 #[arg(long, requires = "tls_key")]
173 tls_cert: Option<PathBuf>,
174
175 #[arg(long, requires = "tls_cert")]
177 tls_key: Option<PathBuf>,
178
179 #[arg(long)]
191 config: Option<PathBuf>,
192}
193
194#[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
224fn 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 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 if let Some(g) = fc.gateway
264 && !is_cli_explicit(matches, "gateway")
265 {
266 self.gateway = g;
267 }
268 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 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 eprintln!("[config] loaded {}", config_path.display());
308 Ok(self)
309 }
310}
311
312#[tokio::main]
313async fn main() -> Result<()> {
314 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 let default_level = if cli.verbose { "debug" } else { "info" };
324
325 let _audit_guard = setup_logging(default_level, cli.audit_log.as_deref())?;
327
328 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 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 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 #[cfg(unix)]
430 spawn_sighup_reload(key_store, state.clone());
431
432 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
450async 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;