1use std::path::PathBuf;
2
3use anyhow::{Context, Result};
4
5use crate::tools;
6
7pub(super) fn render_mcp_metrics_body() -> String {
15 let registry = futu_auth::metrics::global();
16 render_mcp_metrics_body_for(registry.as_deref())
17}
18
19pub(super) fn render_mcp_metrics_body_for(registry: Option<&futu_auth::MetricsRegistry>) -> String {
20 registry.map(|r| r.render_prometheus()).unwrap_or_else(|| {
21 concat!(
22 "# HELP futu_metrics_registry_installed Whether futu_auth metrics registry is installed (1=yes, 0=no)\n",
23 "# TYPE futu_metrics_registry_installed gauge\n",
24 "futu_metrics_registry_installed{state=\"metrics registry not installed\"} 0\n"
25 )
26 .to_string()
27 })
28}
29
30pub(super) async fn serve_http(
31 server: tools::FutuServer,
32 listen: &str,
33 tls: Option<(PathBuf, PathBuf)>,
34) -> Result<()> {
35 use rmcp::transport::streamable_http_server::{
36 StreamableHttpService, session::local::LocalSessionManager,
37 };
38
39 let bind_addr = if listen.starts_with(':') {
41 format!("0.0.0.0{listen}")
42 } else {
43 listen.to_string()
44 };
45
46 let session_manager = std::sync::Arc::new(LocalSessionManager::default());
50 let mcp_svc = StreamableHttpService::new(
51 {
52 let server = server.clone();
53 move || Ok::<_, std::io::Error>(server.clone())
54 },
55 session_manager,
56 Default::default(),
57 );
58
59 use axum::routing::get;
64 let mcp_with_auth_hint = axum::Router::new()
65 .nest_service("/mcp", mcp_svc)
66 .layer(axum::middleware::from_fn(inject_www_authenticate));
67
68 let app = axum::Router::new()
69 .route(
70 "/metrics",
71 get(|| async {
72 let body = render_mcp_metrics_body();
73 (
74 axum::http::StatusCode::OK,
75 [(
76 axum::http::header::CONTENT_TYPE,
77 "text/plain; version=0.0.4",
78 )],
79 body,
80 )
81 }),
82 )
83 .route(
84 "/.well-known/oauth-protected-resource",
85 get(oauth_protected_resource_metadata),
86 )
87 .merge(mcp_with_auth_hint);
88
89 let bind_addr_sock: std::net::SocketAddr = bind_addr
90 .parse()
91 .map_err(|e| anyhow::anyhow!("invalid bind address {bind_addr}: {e}"))?;
92
93 if let Some((cert_path, key_path)) = tls {
94 let tls_config =
96 axum_server::tls_rustls::RustlsConfig::from_pem_file(&cert_path, &key_path)
97 .await
98 .with_context(|| {
99 format!(
100 "load TLS cert={} key={}",
101 cert_path.display(),
102 key_path.display()
103 )
104 })?;
105 let handle = axum_server::Handle::new();
106 let shutdown_handle = handle.clone();
107 tokio::spawn(async move {
108 shutdown_signal().await;
109 tracing::info!("graceful shutdown: draining HTTPS connections...");
110 shutdown_handle.graceful_shutdown(Some(std::time::Duration::from_secs(10)));
111 });
112 tracing::info!(
113 addr = %bind_addr,
114 cert = %cert_path.display(),
115 "futu-mcp HTTPS transport started \
116 (MCP: /mcp, metrics: /metrics, OAuth metadata: /.well-known/oauth-protected-resource)"
117 );
118 axum_server::bind_rustls(bind_addr_sock, tls_config)
119 .handle(handle)
120 .serve(app.into_make_service())
121 .await
122 .map_err(|e| anyhow::anyhow!("axum-server TLS serve error: {e}"))?;
123 } else {
124 let listener = tokio::net::TcpListener::bind(&bind_addr)
126 .await
127 .map_err(|e| anyhow::anyhow!("bind {bind_addr}: {e}"))?;
128 tracing::info!(
129 addr = %bind_addr,
130 "futu-mcp HTTP transport started \
131 (MCP: /mcp, metrics: /metrics, OAuth metadata: /.well-known/oauth-protected-resource)"
132 );
133 axum::serve(listener, app)
134 .with_graceful_shutdown(async {
135 shutdown_signal().await;
136 tracing::info!("graceful shutdown: draining HTTP connections...");
137 })
138 .await
139 .map_err(|e| anyhow::anyhow!("axum serve error: {e}"))?;
140 }
141 tracing::info!("server stopped");
142 Ok(())
143}
144
145async fn shutdown_signal() {
148 #[cfg(unix)]
149 {
150 use tokio::signal::unix::{SignalKind, signal};
151 let sigterm = match signal(SignalKind::terminate()) {
152 Ok(signal) => Some(signal),
153 Err(e) => {
154 tracing::error!(error = %e, "failed to install SIGTERM handler");
155 None
156 }
157 };
158 let sigint = match signal(SignalKind::interrupt()) {
159 Ok(signal) => Some(signal),
160 Err(e) => {
161 tracing::error!(error = %e, "failed to install SIGINT handler");
162 None
163 }
164 };
165
166 match (sigterm, sigint) {
167 (Some(mut sigterm), Some(mut sigint)) => {
168 tokio::select! {
169 _ = sigterm.recv() => tracing::info!("received SIGTERM"),
170 _ = sigint.recv() => tracing::info!("received SIGINT"),
171 }
172 }
173 (Some(mut sigterm), None) => {
174 sigterm.recv().await;
175 tracing::info!("received SIGTERM");
176 }
177 (None, Some(mut sigint)) => {
178 sigint.recv().await;
179 tracing::info!("received SIGINT");
180 }
181 (None, None) => wait_for_ctrl_c_or_pending().await,
182 }
183 }
184 #[cfg(not(unix))]
185 {
186 wait_for_ctrl_c_or_pending().await;
187 }
188}
189
190async fn wait_for_ctrl_c_or_pending() {
191 match tokio::signal::ctrl_c().await {
192 Ok(()) => tracing::info!("received Ctrl-C"),
193 Err(e) => {
194 tracing::error!(
195 error = %e,
196 "failed to install ctrl-c handler; graceful shutdown signal unavailable"
197 );
198 std::future::pending::<()>().await;
199 }
200 }
201}
202
203pub(super) async fn oauth_protected_resource_metadata() -> axum::response::Json<serde_json::Value> {
215 axum::response::Json(serde_json::json!({
216 "resource": "/mcp",
217 "bearer_methods_supported": ["header"],
218 "scopes_supported": [
219 "qot:read",
220 "acc:read",
221 "trade:simulate",
222 "trade:real",
223 "trade:unlock"
224 ],
225 "resource_name": "FutuOpenD-rs MCP",
226 "resource_documentation": "https://futuapi.com/reference/mcp/",
227 }))
228}
229
230pub(super) async fn inject_www_authenticate(
236 req: axum::extract::Request,
237 next: axum::middleware::Next,
238) -> axum::response::Response {
239 let mut resp = next.run(req).await;
240 let status = resp.status();
241 if (status == axum::http::StatusCode::UNAUTHORIZED
242 || status == axum::http::StatusCode::FORBIDDEN)
243 && !resp
244 .headers()
245 .contains_key(axum::http::header::WWW_AUTHENTICATE)
246 {
247 let value = axum::http::HeaderValue::from_static(
250 "Bearer resource_metadata=\"/.well-known/oauth-protected-resource\"",
251 );
252 resp.headers_mut()
253 .insert(axum::http::header::WWW_AUTHENTICATE, value);
254 }
255 resp
256}